Make the parent-death watchdog work on Windows, and the test suite honest there - #582
Merged
Conversation
… suite honest there Thirteen tests fail on a Windows checkout of main. All three causes are real, and one of them is a shipped bug that CI cannot see because CI runs Linux. ## The watchdog never fires on Windows (#579) `process_exists` took a successful `OpenProcess` as proof of life. A Windows process object outlives the process and is destroyed only when the last handle to it closes, so `OpenProcess` keeps succeeding on the pid of something that exited while anyone still holds a handle -- and someone almost always does, namely whoever spawned it and has not reaped it. That is exactly the case the watchdog exists for. A parent killed by Force Quit, Task Manager or a crash runs no cleanup, so nothing is ever reaped, so the check kept answering "alive" and the worker kept its GPU. The failure this was written to prevent is the failure it could not detect. `GetExitCodeProcess` is the call that separates the two states. Its one documented ambiguity, a process that genuinely exits with code 259, lands on the "alive" side, which is the side this function must fail towards: a watchdog that shoots on a question it could not answer kills a live separation. `tests/test_process_liveness.py` asserted the old behaviour as correct, so it is rewritten around the corrected semantics: still-active is alive, an open handle with a real exit code is dead, and an unreadable exit code is alive. The fake kernel32 grows `GetExitCodeProcess` and the handle-leak assertion is kept on every path, since this runs on a one-second timer. ## The log-zip test asserted bytes it never wrote (#580) `Path.write_text` opens in text mode, which turns `\n` into `\r\n` on Windows. The endpoint then zipped the file byte for byte, correctly, and the test compared it against the string it thought it had written. A test about byte preservation has to control its own bytes, so the fixture now passes `newline=""`. ## Beat-grid tests failed unreadably without ffmpeg (#581) `_decode_mono` degrades to `None` rather than raising, deliberately: everything it feeds is a display field. In a test that makes a missing binary and an undetectable grid produce the same `assert grid is not None`, with the real cause only in a log record nothing asserts on. The `stems_dir` fixture now skips when ffmpeg cannot be resolved, through the same `ffmpeg_executable()` the app uses, so a portable install or a `STEMDECK_FFMPEG_DIR` pointing at a bundled build still counts as present. The skip reason names that variable rather than leaving someone to guess. The guard covers all twelve tests taking the fixture, not just the ten that went red. The four `test_returns_none_*` cases were passing without ffmpeg for the wrong reason: they got their None from the decode failing rather than from the audio, so they could not have caught a regression in what they test. Conditional on the binary, never on the platform, so CI keeps full coverage: `.github/workflows/ci.yml` installs ffmpeg into the test container. ## Verified both ways Without ffmpeg: 945 passed, 52 skipped, none failed. With `STEMDECK_FFMPEG_DIR` set: 997 passed, nothing skipped, which is what CI runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thirteen tests fail on a Windows checkout of
main. Three separate causes, andone of them is a shipped bug that CI cannot see because CI runs Linux.
1. The parent-death watchdog never fires on Windows (#579)
This is the one that matters.
process_existstreated a successfulOpenProcessas proof of life:A Windows process object outlives the process. It is destroyed only when the
last handle to it closes, so
OpenProcesskeeps succeeding on the pid ofsomething that exited while anyone still holds one, and someone almost always
does: whoever spawned it and has not reaped it.
That is precisely the scenario the watchdog exists for. A parent killed by Force
Quit, Task Manager or a crash runs no cleanup, so nothing is ever reaped, so the
check kept answering "alive" and the worker kept its GPU. The failure it was
written to prevent is the failure it could not detect.
GetExitCodeProcessis the call that separates the two states. Its onedocumented ambiguity, a process that genuinely exits with code 259, lands on the
"alive" side, which is the side this function has to fail towards: a watchdog
that shoots on a question it could not answer kills a live separation.
tests/test_process_liveness.pyasserted the old behaviour as correct, so it isrewritten around the corrected semantics: still-active is alive, an open handle
with a real exit code is dead, an unreadable exit code is alive. The fake
kernel32 grows
GetExitCodeProcess, and the handle-leak assertion is kept onevery path since this runs on a one-second timer.
2. The log-zip test asserted bytes it never wrote (#580)
Path.write_textopens in text mode, which turns\ninto\r\non Windows.The endpoint zipped the file byte for byte, correctly, and the test compared the
result against the string it thought it had written. A test about byte
preservation has to control its own bytes, so the fixture passes
newline="".The endpoint was never wrong. Only the fixture was.
3. Beat-grid tests failed unreadably without ffmpeg (#581)
_decode_monodegrades toNonerather than raising, deliberately: everythingit feeds is a display field. In a test that makes a missing binary and a
genuinely undetectable grid produce the same
assert grid is not None, with thereal cause only in a log record nothing asserts on.
The
stems_dirfixture now skips when ffmpeg cannot be resolved, through thesame
ffmpeg_executable()the app uses, so a portable install or aSTEMDECK_FFMPEG_DIRpointing at a bundled build still counts as present. Theskip reason names that variable rather than leaving someone to guess.
The guard covers all twelve tests taking the fixture, not just the ten that
went red. The four
test_returns_none_*cases were passing without ffmpeg forthe wrong reason: they got their
Nonefrom the decode failing rather than fromthe audio, so they could not have caught a regression in what they actually
test.
Conditional on the binary, never on the platform, so CI keeps full coverage:
.github/workflows/ci.ymlinstalls ffmpeg into the test container explicitly.Verified both ways
STEMDECK_FFMPEG_DIRset (what CI runs)The second row is the one that matters: the guarded tests still run and still
pass when the binary is there, so nothing has been quietly switched off.
Also run:
ruff check,ruff format --check,bandit -r app/ -ll(exit 0).Why this is worth landing
The documented pre-push gate says to run the whole suite before pushing. On
Windows that gate has been returning thirteen failures that are not about your
change, which teaches you to skim past the summary. That is how a real failure
gets missed. Fixing it makes the gate usable on the platform, and on the way
found a watchdog that has not worked there.
Closes #579
Closes #580
Closes #581
🤖 Generated with Claude Code