Skip to content

Windows: stop_run always force-kills after a full 10s wait — the engine's SIGTERM path is unreachable #319

Description

@pbean

Summary

On native Windows, stop_run's preferred path — the engine's own SIGTERM handler — cannot fire. Every bmad-loop stop therefore burns the full _STOP_WAIT_S = 10.0 grace window doing nothing, force-kills, and completes through the fallback branch.

stop_run's docstring (runs.py:442) states the intent:

Prefers the engine's own SIGTERM handler so the engine stays the single writer of stopped (it marks the run, kills its in-flight agent window, and exits).

That preference is POSIX-only in practice.

Why it can't fire

  • WindowsProcessHost.terminate (process_host.py:195-199) is taskkill /PID without /F, run with check=False, capture_output=True — so its failure is silently discarded.
  • Graceful taskkill posts WM_CLOSE. The engine is a console process with no message loop, so nothing receives it.
  • Engine._install_stop_signals does register SIGTERM (engine.py:541), but on Windows no external tool can deliver a Python-level SIGTERM: taskkill posts WM_CLOSE, taskkill /F calls TerminateProcess, and neither runs the interpreter's handler. (The win32 branch at engine.py:530 deliberately ignores SIGINT/SIGBREAK console-ctrl events, so those aren't a route either.)

stop_run then waits out the grace window, force-kills via taskkill /F /T, finds stopped unset, and falls through to the fallback that marks it externally.

Evidence

Measured on a Windows VM (PowerShell, CPython 3.13, stock PATH) while verifying #293 in PR #314:

> uv run pytest tests/test_runs.py::test_stop_run_signals_live_process -q --durations=0
1 passed in 11.19s
   10.26s call     tests/test_runs.py::test_stop_run_signals_live_process

10.26s is _STOP_WAIT_S consumed in full: host.is_alive(pid) stayed true for the entire window after terminate() returned. That is a direct measurement of graceful taskkill failing to close a console child, not an inference.

The same run on Linux completes in well under a second.

Impact

Correctness is preserved — the fallback marks stopped and kill_session backstops the agent window — so this is not a live defect and not a regression (it predates #314; sleep.exe had the identical shape). What it costs:

  • Every bmad-loop stop on native Windows takes ~10s.
  • The engine never performs its own teardown: it doesn't mark stopped, doesn't kill its in-flight agent window, and is force-killed mid-operation instead of unwinding through RunStopped. Any invariant that assumes the engine is the single writer of stopped holds only by fallback.
  • Every Windows stop journals run-stop with fallback=True (runs.py:505) — a usable signature if anyone wants to confirm this in the field.
  • tests/test_runs.py::test_stop_run_signals_live_process is ~10s on the Windows lane, by design rather than by fault.

Possible directions (needs a design call)

  1. A hard-stop control file. The graceful-stop mechanism (request_graceful_stop, stop-request.json, feat: graceful stop for bmad-loop runs (finish the in-flight item, then stop — resumable) #219) already establishes a deterministic file-based channel the engine polls. Reusing that shape for hard stop would be cross-platform and wouldn't depend on signal semantics at all.
  2. GenerateConsoleCtrlEvent / CTRL_BREAK_EVENT. Reaches a console process properly, but requires the engine be spawned with CREATE_NEW_PROCESS_GROUP, and the win32 branch in _install_stop_signals currently ignores SIGBREAK — so both ends would have to change together.
  3. Shorten _STOP_WAIT_S on win32. Treats the symptom only; the engine still never runs its own teardown. Cheapest, and defensible if the tier doesn't justify more.

Native Windows sits below the Linux/macOS/WSL support tier (setup-guide), so option 3 may well be the right scope.

Refs: #293, PR #314

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclebugSomething isn't workingneeds-designAwaiting a maintainer design decision before code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions