Skip to content

Make the parent-death watchdog work on Windows, and the test suite honest there - #582

Merged
thcp merged 1 commit into
mainfrom
fix/windows-local-test-run
Sep 6, 2026
Merged

Make the parent-death watchdog work on Windows, and the test suite honest there#582
thcp merged 1 commit into
mainfrom
fix/windows-local-test-run

Conversation

@thcp

@thcp thcp commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Thirteen tests fail on a Windows checkout of main. Three separate causes, and
one 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_exists treated a successful
OpenProcess as proof of life:

handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if handle:
    kernel32.CloseHandle(handle)
    return True

A Windows process object outlives the process. It 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 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.

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 has to 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, 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.

2. 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 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_mono degrades to None rather than raising, deliberately: everything
it 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 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 actually
test.

Conditional on the binary, never on the platform, so CI keeps full coverage:
.github/workflows/ci.yml installs ffmpeg into the test container explicitly.

Verified both ways

Environment Result
No ffmpeg (a bare dev machine) 945 passed, 52 skipped, 0 failed
STEMDECK_FFMPEG_DIR set (what CI runs) 997 passed, 0 skipped

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

… 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>
@thcp
thcp merged commit d9abd9b into main Sep 6, 2026
10 checks passed
@thcp
thcp deleted the fix/windows-local-test-run branch September 6, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant