Skip to content

fix(engine): close the divert before saying why a session stopped - #212

Merged
donislawdev merged 2 commits into
masterfrom
fix/fail-open-does-not-wait-for-log
Sep 28, 2026
Merged

donislawdev merged 2 commits into
masterfrom
fix/fail-open-does-not-wait-for-log

Conversation

@donislawdev

@donislawdev donislawdev commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

Every engine stop path passed its reason to the log callback before it stopped. The CLI log is a synchronous stderr write. A console window with a text selection (QuickEdit), or a pipe nobody reads, holds that write. While it is held, the divert stays open and the network stays impaired.

Reproduced with a log callback that blocks on the line each path says, on fake diverts only (no real capture). All six paths kept the divert open for exactly as long as the log was held:

path what it said first
--duration deadline (watchdog) Time limit reached (... s) - session stopped.
recv error (capture thread) recv error: ..., then the engine fault
dead worker thread (watchdog) Engine fault: worker thread ... died unexpectedly ...
capture thread stall (watchdog) Engine fault: capture thread stopped making progress ...
scenario timeline failure (runner) Scenario stopped: ...
start() failing after the divert opened Engine fault: ...

A log callback that raised was worse. On the deadline line it killed the watchdog, so the session never stopped and nothing was left watching it. At start, the failure handler's own fault line raised before it could stop, so the divert stayed open and the caller got the log's error instead of the real one.

The fix

  • BeanEngine.log is now a method around the callback. An exception from it is recorded in the crash log and goes no further, so it can no longer kill a worker thread or cut a teardown short.
  • _stop_locked(reason, say=()) says the reason lines at the very end of the teardown. That is after the divert and the other handles are closed, the worker threads are joined, the timer request and the thread switch interval are released, and the engine is removed from the atexit list. They still come before Stop., so the log reads in the same order as before. If the session is already stopped, it says them straight away.
  • _worker_stop and _fault_stop_blocking take the same lines and say them when they bow out to another stop, which is the one closing the divert.
  • _fail_stop(..., lead=()) takes the recv-error line instead of the capture thread saying it first. The deadline and the start() failure handler go through say too.
  • The scenario runner calls engine.worker_failed before it logs the failure. That line stays on the runner's own log, so Scenario stopped: ... now comes after Stop.. This is the one visible change in order.

The GUI log was never affected: it only puts lines on a queue.

Guards

  • test_failsafe.py::test_a_stop_closes_the_divert_while_the_log_is_still_blocked holds the log on each of the six paths. The divert must close while the log is still held, and once the log moves the reason must come before Stop..
  • test_failsafe.py::test_a_log_that_raises_cannot_cancel_a_stop covers a log that raises: the deadline still stops the session, and a failed start still closes the divert and raises its own error.
  • test_scenario_runner.py::test_a_broken_timeline_stops_the_session_before_it_says_so checks the runner order.
  • Eight new mutation entries: one per call site, the placement at the end of the teardown, the log wrapper and the runner order. All eight were run with tools/mutate.py and all were caught.

Review follow-up (second commit)

The first commit said the reason right after the handles were released, before the worker joins and the release of the timer request, the thread switch interval and the atexit entry. With a log held for 1.2 s at the deadline, the divert was closed, but the engine was still on the atexit list and still held the timer request and the switch interval until the log moved. The second commit moves the lines to the end of the teardown. The blocked-log test now also requires the session to be fully torn down while the log is held, and a new mutation that moves the lines back is caught.

Two other review points were considered and left as they are:

  • The scenario runner still logs its failure on its own thread. By then the session is fully stopped. Nothing joins that thread, and it holds no session resources. A queued, non-blocking CLI log is a separate decision.
  • A worker that loses the race for the stop lock says its lines while another thread holds the stop. The divert is closed by that other thread, so the close never waits for this log. Only the position of that line in the log can vary during a simultaneous STOP, as it did before.

Not in this change

  • The START line (Start. Filter: ...) is still said while the divert is open, before the capture thread exists, under the stop lock. That window is being reworked in a separate change to the start order.
  • A warning said on the injector thread while the log is held stops delivery, and no watchdog sees that yet. This goes with the planned injector stall check.
  • A warning on the capture thread held by the log is now bounded by the existing 10 s stall check. Before this change, that check could not stop the session either.

Checks run locally

  • internal_tools/guards.py --strong --run --lint: 97 test files, ruff and mypy on the changed modules. It found two failures:
    • a changelog entry over the 100-word limit, now shortened, and its test file passes;
    • test_target_resolver.py::test_repeated_start_stop_cycles_do_not_stack_resolver_threads. This is a timing flake that already exists on master. Under sustained CPU load, measured alternately on this branch and on master: 3 of 15 red here, 4 of 15 red on master. It passes 6 of 6 in isolation. The resolver does not wait for a scan in progress by design, and the test allows the thread only 0.2 s.
  • The full suite was not run locally. CI runs it on Linux and Windows.

🤖 Generated with Claude Code

Every engine stop path passed its reason to the log callback before it
stopped: the duration deadline, a recv error, a dead worker, a stalled
capture thread, a failed scenario timeline and a start() that failed
halfway. The CLI log is a synchronous stderr write, and a console with a
text selection (QuickEdit) or a pipe nobody reads holds that write. The
divert then stayed open, with the network impaired, for as long as the
write was held. A log callback that raised was worse: on the deadline
line it killed the watchdog, so the session never stopped.

- BeanEngine.log is now a method around the callback; an exception from
  it is recorded in the crash log and goes no further.
- _stop_locked takes the lines to say and says them after every handle
  of the session is released, before "Stop.", so the log keeps its order.
  _worker_stop and _fault_stop_blocking say them when they bow out.
- The deadline, the recv error and the start() failure handler hand
  their lines to the stop instead of saying them first.
- The scenario runner tells the engine before it logs the failure.

Guards: a test that holds the log on all six paths and requires the
divert to close while it is held, a test with a log that raises, a
runner order test, and seven mutation entries (all caught).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 18e4e9bd-8236-45c3-ba02-282c6d577658

📥 Commits

Reviewing files that changed from the base of the PR and between b0adaf3 and 73da925.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • beantester/engine.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • tests/test_scenario_runner.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (9)
  • GitHub Check: pip-audit (advisories against the pinned set)
  • GitHub Check: tests (windows-latest, py3.14)
  • GitHub Check: mypy
  • GitHub Check: ruff (F, B, C90 and PLR0913 block, S and ASYNC report)
  • GitHub Check: tests (ubuntu-latest, py3.14)
  • GitHub Check: mutation registry
  • GitHub Check: semgrep (ERROR, HIGH and CRITICAL block)
  • GitHub Check: Analyze (actions)
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (14)
Applies to text shown to the user (labels, buttons, tooltips, placeholders, dialogs, errors, status messages, empty states, translations).

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
Verify tests check real behavior and would fail if the implementation were broken.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
Domain: network condition simulator (latency, loss, throttling, disconnects) built on WinDivert via PyDivert, with a Tkinter GUI and a CLI over one engine.

⚙️ CodeRabbit configuration file

Files:

  • beantester/scenario_runner.py
  • beantester/engine.py
These are end-user desktop applications.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
Performance is a known weak spot of these projects.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
Applies only to code that builds or styles a GUI.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
User-facing changelog.

⚙️ CodeRabbit configuration file

Files:

  • CHANGELOG.md
SECURITY, HIGH PRIORITY.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
These apps are QA/developer tools.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
Check that documentation matches the actual code in this PR: commands, flags, config keys, file paths, build steps and examples must exist.

⚙️ CodeRabbit configuration file

Files:

  • CHANGELOG.md
Python code.

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
All code in this repository is written by an AI coding agent (Claude Code).

⚙️ CodeRabbit configuration file

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • CHANGELOG.md
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
Source excerpt: **Flat hyphen only.**

📄 CodeRabbit inference engine (.github/claude-review-rules.md)

Files:

  • tests/test_scenario_runner.py
  • beantester/scenario_runner.py
  • CHANGELOG.md
  • tests/test_failsafe.py
  • tests/test_mutation_registry.py
  • beantester/engine.py
Source excerpt: **Anything visible from outside goes in the changelog.**

📄 CodeRabbit inference engine (.github/claude-review-rules.md)

Files:

  • CHANGELOG.md
🪛 LanguageTool
CHANGELOG.md

[locale-violation] ~14-~14: In American English, ‘afterward’ is the preferred variant. ‘Afterwards’ is more commonly used in British English and other dialects.
Context: ...n now stops first and prints the reason afterwards. When a scenario fails, its "Scenario...

(AFTERWARDS_US)

🔇 Additional comments (7)
beantester/engine.py (2)

221-239: LGTM!


1122-1158: LGTM!

tests/test_failsafe.py (1)

1323-1492: LGTM!

tests/test_mutation_registry.py (1)

458-524: LGTM!

CHANGELOG.md (1)

10-16: LGTM!

beantester/scenario_runner.py (1)

119-123: LGTM!

tests/test_scenario_runner.py (1)

337-364: LGTM!


📝 Walkthrough

Walkthrough

Engine stop paths now close session resources before logging stop reasons. Log sink exceptions are recorded instead of propagated. Scenario failures notify the engine before invoking the configured logger.

Changes

Stop teardown and failure reporting

Layer / File(s) Summary
Engine logging and stop teardown
beantester/engine.py, tests/test_failsafe.py, tests/test_mutation_registry.py, CHANGELOG.md
BeanEngine.log catches sink exceptions and records them with crashlog.note. Stop paths pass explanatory messages through teardown, which closes the divert and stops the resolver and socket watcher before emitting the messages and recording the STOP event. Tests cover blocked and raising log sinks across stop paths. The changelog describes the updated stop-message ordering.
Scenario failure notification order
beantester/scenario_runner.py, tests/test_scenario_runner.py, tests/test_mutation_registry.py
ScenarioRunner._loop calls engine.worker_failed(exc) before invoking the caller-provided logger. A regression test checks that the engine has been notified when the failure is logged.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Watchdog
  participant BeanEngine
  participant Divert
  participant Resolver
  participant SocketWatcher
  participant LogSink
  Watchdog->>BeanEngine: Pass duration stop message
  BeanEngine->>Divert: Close divert
  BeanEngine->>Resolver: Stop resolver
  BeanEngine->>SocketWatcher: Stop socket watcher
  BeanEngine->>LogSink: Emit stop message
  BeanEngine->>BeanEngine: Record STOP event
Loading

Merge Risk: ⚪ Minimal · up to 73da9

The stop and failure-reporting changes are mergeable after normal checks; no actionable issue remains.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 73da9

The changed stop paths generally close the network diversion before calling a logger that might block or fail. A concurrent-stop path still lacks that ordering guarantee, but the same exposure existed before this change. The design merits review because safe shutdown depends on this ordering, and real-driver behavior has not been established by the available evidence.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The relevant exposure is a locally running session’s diverted network traffic, rather than a newly exposed remote entrypoint. A blocked callback can affect shutdown timing for that session.

Security Findings and Attack Paths

  • inferred — A blocked logger can still run before closure when a worker loses the stop-lock race. The base path also logged before that worker attempted to stop, so the evidence does not establish this as a new or worsened PR security condition.

Trust Boundaries and Controls

  • observed — The log callback remains caller-supplied code. The engine now contains ordinary callback exceptions, while the principal control against a blocking callback is to release session resources before invoking it.

Resilience and Maintainability Implications

  • observed — The capture-fault path can wait for an in-progress start, but reports without waiting when another stop has marked the session not running. That distinction avoids waiting on a stop that may be joining the capture thread; it does not provide a teardown-completion barrier for reporting.

Hardening Proposals

  • proposed — If teardown-before-reporting must hold under concurrent stops, arrange for the stop owner to report a losing worker’s reason after closure, without making that worker wait on a lock holder that may join it.
🚥 Pre-merge checks | ✅ 13 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
No Resource Leaks ⚠️ Warning The new _stop_locked calls _say(say) at beantester/engine.py:1201, before joining _t_cap, _t_inj, and _t_wd, releasing _fine_timers and _fast_switch, clearing _heap, and discarding `… Complete all session cleanup before invoking any potentially blocking caller log sink. Move _say(say) until after worker joins, timer releases, queue cleanup, and _LIVE_ENGINES.discard(self), while keeping it before the Stop. log line…
✅ Passed checks (13 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Tests For Changed Behavior ✅ Passed The PR changes runtime stop, logging, and scenario-failure behavior and adds direct tests for it. tests/test_failsafe.py adds blocked-log coverage for six stop paths plus a raising-log test; `tests/…
No Secrets Or Debug Leftovers ✅ Passed The PR changes six existing project files only. The added lines contain no CLAUDE/AGENTS/.claude files, .env files, credentials, tokens, private URLs, local absolute paths, hostnames, IP addresses, pe…
No Hardcoded Ui Styling ✅ Passed The pull request changes only Markdown and Python engine, scenario-runner, and test files. It does not add or modify XAML, Slint, Fyne, Tkinter, WPF, or other GUI code. The no-hardcoded-UI-styling che…
No Obvious Performance Problems ✅ Passed No clear performance problem is introduced. The new _say loop processes only a small stop-reason tuple and runs on watchdog, capture, or scenario-worker paths. GUI logging remains queue-based, and G…
Desktop Robustness ✅ Passed The authoritative diff changes engine logging/teardown ordering and scenario-failure handling. It adds no asset loading, settings/data persistence, culture-sensitive number/date handling, network requ…
Safe File Parsing ✅ Passed The PR does not add file parsing, import, or export behavior for XML, XAML, CSV, XLSX, JSON, YAML, translations, themes, settings, or archives. The changed production code only wraps the log callback …
System Changes Are Reversible ✅ Passed PASS. The PR changes logging and stop ordering; it does not add or broaden a network filter, proxy, firewall rule, driver, service, registry change, or process injection. The existing engine lifecycle…
Clear User-Facing Text ✅ Passed No clear-text defect was introduced. The PR changes the order of existing stop messages, but the affected English, Polish, and Chinese message values are byte-identical between base and head. Existing…
Scope, Duplication And Docs ✅ Passed The change is scoped to the stated engine stop/logging fix, its scenario-failure ordering, related tests, and a matching CHANGELOG entry. The six changed files contain no unrelated refactor, UI, style…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main behavior change: the divert closes before the session-stop reason is logged. It is specific, relevant to the changeset, and within the length limit.
Full details: No Resource Leaks

Explanation

The new _stop_locked calls _say(say) at beantester/engine.py:1201, before joining _t_cap, _t_inj, and _t_wd, releasing _fine_timers and _fast_switch, clearing _heap, and discarding _LIVE_ENGINES at lines 1211-1253. The PR explicitly supports a log sink that can block indefinitely. Therefore a deadline or worker-fault sink can leave the watchdog or capture thread blocked in the sink, retain the process timer requests, queue memory, and live-engine tracking indefinitely. The new scenario path has the same issue: ScenarioRunner._loop calls engine.worker_failed() and then invokes the caller log at scenario_runner.py:122-123; a blocked CLI sink can strand the scenario runner thread after teardown begins.

Resolution

Complete all session cleanup before invoking any potentially blocking caller log sink. Move _say(say) until after worker joins, timer releases, queue cleanup, and _LIVE_ENGINES.discard(self), while keeping it before the Stop. log line. Do not invoke the scenario failure sink on the scenario worker; use a bounded, non-blocking logging handoff with a cancellation/stop path so a blocked sink cannot retain engine or scenario threads.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The reason lines were said right after the divert and the other handles
were released, but before the worker joins, the release of the timer
request and the thread switch interval, the queue cleanup and the
atexit entry. With a log that blocks, all of those stayed held until the
log moved. They are now said at the very end, just before "Stop.", so
the log reads in the same order and a held log keeps nothing of the
session.

The blocked-log test now also requires the session to be fully torn
down while the log is held, and a new mutation puts the lines back
before the rest of the teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit c333071 into master Sep 28, 2026
15 checks passed
@donislawdev
donislawdev deleted the fix/fail-open-does-not-wait-for-log branch September 28, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant