Skip to content

fix: bounded timeouts and reliable async teardown, plus three client bugs - #3

Merged
jqnatividad merged 8 commits into
mainfrom
fix/review-findings
Oct 9, 2026
Merged

jqnatividad merged 8 commits into
mainfrom
fix/review-findings

Conversation

@jqnatividad

Copy link
Copy Markdown
Contributor

Summary

Fixes from a codebase review, plus follow-ups from automated (roborev) reviews of those fixes. Every fix has a regression test that fails on the previous code.

Process teardown (_process.py)

  • Timeouts are bounded again. If a process qsv started left its process group (e.g. via setsid), it held the output pipes open, and timeout=1 waited for that process to exit. After a kill, output is now collected for at most kill_grace. This also holds when qsv ignores SIGTERM. On Python 3.14, asyncio's proc.wait() waits for the pipes to close, so every teardown wait is now bounded.
  • No leaked descriptors. After a timeout or cancellation, AsyncQsv closes its pipe transport. Before, each run whose pipes were held by such a process leaked 2 fds until that process exited.
  • Cancellation always kills. Cancelling an AsyncQsv run, including mid-cleanup or through asyncio.run() shutdown, now SIGKILLs the process group. The teardown finishes before the cancellation propagates, and if the teardown itself is cancelled it sends SIGKILL and closes the pipes synchronously.
  • On Windows, the post-kill wait is bounded too. That change is untested, since Windows is not in CI.

Client (client.py)

  • A failed run with non-UTF-8 stdout raises the typed QsvError instead of UnicodeDecodeError.
  • count()/headers() work with stdout_path= and text=False instead of raising IndexError.
  • find_qsv returns absolute paths, so a relative binary works together with cwd=. It uses Path.absolute(), which leaves .. alone and so stays correct after a symlinked directory.
  • Documents kill_grace.

CI

  • Adds Python 3.14 to the test matrix.

Test plan

  • uv run pytest: 55 passed on Python 3.14, also with -W error::ResourceWarning
  • tests/test_process.py on Python 3.10
  • ruff format --check, ruff check, mypy (strict) clean
  • Integration tests against qsv 24.0.0
  • CI matrix (ubuntu/macos x 3.10–3.14; qsv, qsvlite, qsvdp)

🤖 Generated with Claude Code

jqnatividad and others added 8 commits October 9, 2026 10:49
…, and more

- Timeout: after killing the process group, collect output for at most
  kill_grace seconds. A descendant that left the group (setsid) could
  otherwise hold the pipes open and stall the timeout indefinitely.
- _finish: raise the typed QsvError before decoding stdout, so a failed
  run with non-UTF-8 stdout no longer surfaces as UnicodeDecodeError.
- count()/headers(): read output via QsvResult._text(), so they work
  with stdout_path= and text=False instead of raising IndexError.
- find_qsv: return absolute paths, so a relative binary works with cwd=.
- Document kill_grace in the client docstring.

Each fix has a regression test that fails on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ary paths

Addresses roborev review 5154:

- AsyncQsv: proc.wait() also waits for the output pipes to close, so with
  a SIGTERM-ignoring leader and a descendant that left the group, the
  post-SIGKILL wait blocked until that descendant exited. Every teardown
  wait is now bounded by kill_grace.
- AsyncQsv: after a timeout or cancellation, close the subprocess
  transport so pipes held by an escaped descendant no longer keep two fds
  open per run until it exits, then reap the killed leader.
- find_qsv: use Path.absolute() instead of os.path.abspath, which
  collapsed `..` lexically and pointed at the wrong file after a
  symlinked directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses roborev review 5155:

- A cancellation arriving while the timeout handler was cleaning up (e.g.
  an outer asyncio deadline during the SIGTERM grace wait) escaped the
  handler, skipping the SIGKILL and the pipe release; a SIGTERM-ignoring
  qsv kept running. The timeout cleanup now runs shielded, as the
  cancellation path already did.
- Windows: bound the post-kill wait by kill_grace, since proc.wait()
  also waits for pipes a surviving child may hold. Untested (no Windows
  CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses roborev review 5157: asyncio.shield() re-raised the
cancellation at once and left the cleanup in a background task. When
asyncio.run() returned right after an outer deadline, the runner's
shutdown cancelled that task mid-SIGTERM-wait, skipping the SIGKILL and
leaving a SIGTERM-ignoring qsv running. Both the timeout cleanup and the
cancellation path now run the teardown to completion, then re-raise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses roborev review 5158: when asyncio.run() returns while a
background run is still in timeout cleanup, the runner cancels the
cleanup task directly, mid-SIGTERM-wait, so the SIGKILL and pipe close
never ran. Teardown now catches its own cancellation, skips the
remaining grace, sends SIGKILL to the group and closes the pipes
synchronously, then re-raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux with Python 3.12+, asyncio reaps children through a pidfd
reader on the event loop. A leader SIGKILLed while asyncio.run() shuts
down is therefore never reaped by it, and os.kill(pid, 0) keeps
succeeding on the zombie, failing
test_asyncio_run_cancelling_background_run_mid_cleanup_still_kills on
ubuntu 3.12-3.14 although the process was killed. Check /proc/<pid>/stat
for state Z as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jqnatividad
jqnatividad merged commit ae01677 into main Oct 9, 2026
11 checks passed
@jqnatividad
jqnatividad deleted the fix/review-findings branch October 9, 2026 15:32
jqnatividad added a commit that referenced this pull request Oct 9, 2026
Bump the version to 0.2.0 in pyproject.toml, __version__ and uv.lock,
and move the Unreleased changelog entries under 0.2.0 with PR links:
Windows support via Job Objects, bounded timeouts and reliable async
teardown, and the client fixes from #3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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