Skip to content

feat: Windows support via Job Objects - #5

Merged
jqnatividad merged 5 commits into
mainfrom
feat/windows-job-objects
Oct 9, 2026
Merged

jqnatividad merged 5 commits into
mainfrom
feat/windows-job-objects

Conversation

@jqnatividad

@jqnatividad jqnatividad commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Windows, a timeout or cancellation used to kill only qsv.exe; anything it started kept running. Each run now goes in its own Job Object, called through ctypes, so there's still no runtime dependency:

  1. CTRL_BREAK_EVENT to the run's console process group (the polite stop, like SIGTERM)
  2. after kill_grace, TerminateJobObject, which kills every process in the job

Child processes can't leave the job, unlike a POSIX process group, which setsid escapes.

Changes

  • _process.py: a per-run _ProcessTree (a POSIX session, or a Windows Job Object) with interrupt / kill / close. The timeout, cancellation and KeyboardInterrupt teardown logic is now shared across platforms.
  • client.py: with inherit_env=False on Windows, SYSTEMROOT is still passed on, because many programs fail to start without it.
  • Tests: on Windows the fake qsv is a .py script behind a .cmd wrapper. Process liveness uses GetExitCodeProcess, because os.kill(pid, 0) terminates the process on Windows. The escaped-grandchild tests (grandchild in its own console process group, out of CTRL_BREAK_EVENT's reach) assert that the Job Object killed it. The symlink .. test is POSIX-only, because Win32 collapses .. before following symlinks.
  • CI: the full suite runs on Windows for Python 3.10–3.14, and the lint job also runs mypy --platform win32.
  • Docs: README platform notes, CHANGELOG, and the Operating System :: Microsoft :: Windows classifier.

Hardening from review

  • No startup race: qsv is created with CREATE_SUSPENDED, assigned to its job, and only then resumed. Its main thread is found through a Toolhelp snapshot, because Popen closes the thread handle. Nothing qsv starts can land outside the job. If resuming fails, the process is killed and the run raises OSError.
  • Second Ctrl-C (all platforms): an interrupt during the grace wait no longer skips the forced kill. The leader is reaped, and its pipes are closed.
  • Windows stdin on Python 3.10–3.12: communicate() wrote stdin synchronously before checking its timeout, so a child that stopped reading could hang the run. stdin is now fed from a thread.

Test plan

  • POSIX: 58 tests pass; ruff, mypy, and mypy --platform win32 are clean
  • Windows CI green on 3.10–3.14 (55 tests each), including the escaped-grandchild Job Object assertions and the large-stdin timeout tests
  • All 16 CI jobs green

🤖 Generated with Claude Code

jqnatividad and others added 5 commits October 9, 2026 11:44
On Windows only qsv.exe itself was killed on a timeout or cancellation;
everything it started survived. Each run is now assigned to its own Job
Object (ctypes, no new dependency): CTRL_BREAK_EVENT to its console
process group first, then TerminateJobObject after kill_grace. Children
cannot leave the job, unlike a POSIX process group.

- _process.py: a _ProcessTree per run (POSIX session or Windows job)
  with interrupt/kill/close; the teardown logic is now shared.
- client.py: keep SYSTEMROOT with inherit_env=False on Windows.
- Tests: the fake qsv is a .py behind a .cmd wrapper on Windows; process
  liveness uses GetExitCodeProcess (os.kill(pid, 0) terminates there);
  escaped-grandchild tests assert the job killed it.
- CI: full suite on Windows for Python 3.10-3.14; lint also runs
  mypy --platform win32.
- Docs: README platform notes, CHANGELOG, Windows classifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses two findings of roborev review 5163:

- A second Ctrl-C during the sync grace wait raised out of
  _terminate_sync before the forced kill, leaving a qsv that ignores
  SIGTERM / CTRL_BREAK_EVENT running (all platforms). The kill now runs
  in a finally, followed by a bounded reap; run_sync also closes output
  pipes an escaping exception would have left open.
- Before Python 3.13, communicate() on Windows writes stdin synchronously
  before checking its timeout, so a child that stops reading could block
  a run forever. On Windows, stdin is now fed from a thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the remaining finding of roborev review 5163. A child qsv
started between CreateProcess and AssignProcessToJobObject landed outside
the job; if it also ignored CTRL_BREAK_EVENT and held the output pipes,
the timeout could block on Windows' pipe reader threads until it exited.

qsv is now created with CREATE_SUSPENDED, assigned to the job, and only
then resumed (its main thread found through a Toolhelp snapshot, since
Popen closes the thread handle). If resuming fails, the process is killed
and the run raises OSError rather than leaving it suspended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses roborev reviews 5164 and 5165: a BaseException raised between
spawning qsv and entering the protected block (Ctrl-C during
_ProcessTree setup, or Thread.start() failing for the Windows stdin
feeder) bypassed teardown, and on Windows could leave qsv suspended.

- _ProcessTree (Windows) kills and closes its job on any BaseException
  during setup, not just a failed resume.
- run_sync / run_async catch any BaseException from setup, kill the
  leader, reap it and close its pipes before re-raising.
- The stdin feeder starts inside the protected block and detaches
  proc.stdin only after its thread started; run_sync's finally now also
  closes stdin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows the interrupting _ProcessTree stand-in replaced the code that
resumes the suspended qsv, so the fake never ran and never wrote the
pidfile the tests waited for. Record the pid handed to the setup and
check that instead, which covers a suspended (Windows) or running
(POSIX) leader alike. _wait_reaped falls back to _wait_dead on Windows,
which has no waitpid/WNOHANG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jqnatividad
jqnatividad marked this pull request as ready for review October 9, 2026 17:14
@jqnatividad
jqnatividad merged commit 6a584cd into main Oct 9, 2026
16 checks passed
@jqnatividad
jqnatividad deleted the feat/windows-job-objects branch October 9, 2026 17:19
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