I use dev-browser on Windows as the execution engine of a browser-test harness, and
kept hitting piped invocations that never returned even though the CLI had long
exited.
Full disclosure: the debugging, the repro, and the suggested fix below were
done by Claude (Opus and some Fable), steered by me. I'm a structural engineer,
and not a SWE. I won't pretend to understand everything, but I try.
I've verified the repro myself. Happy to iterate on any of it, and no hard feelings
if you'd rather solve it differently, or not at all. Fable summary (edited) below:
Environment
- dev-browser 0.2.8 (npm) — also reproduced with a debug build of current
main
- Windows 11, Node 22.x
Steps to reproduce
dev-browser stop # ensure the next client cold-starts the daemon
dev-browser status | Tee-Object -FilePath out.txt
(or in Git Bash: dev-browser status | cat)
What happens
status prints its output, then the pipeline hangs indefinitely — the shell never
sees EOF even though the CLI process has exited. In other words, the output prints,
but the prompt never comes back; you have to Ctrl-C, or run dev-browser stop from
another terminal to release the pipeline. It only happens when the daemon is cold
(the client auto-spawns it); with a warm daemon (e.g. start with a non-piped
dev-browser statues) the same pipeline returns immediately.
In scripted use this bites hard: the first dev-browser invocation after a daemon
death wedges the surrounding pipeline (… | tee log.txt, CI log capture, npm
scripts). It was first noticed as a test-suite pipeline that stayed wedged for
24 minutes after its run had actually completed.
Root cause
spawn_daemon (cli/src/daemon.rs) nulls the daemon's stdio and calls setsid on
Unix, but on Windows CreateProcess still runs with handle inheritance enabled
(Rust's std::process needs it to pass the null stdio handles). That copies
every inheritable handle of the client into the daemon — including the write end
of the stdout pipe the client holds when it runs inside a pipeline. The daemon is
long-lived by design, so that write end never closes and the shell waits forever.
Unix is immune: fds beyond stdio are CLOEXEC and the daemon detaches via setsid.
Evidence it is exactly this: clearing HANDLE_FLAG_INHERIT on just the client's
three std handles for the duration of the spawn makes the repro pass. An A/B of two
debug builds of main on the same machine: the unpatched control hangs (killed by a
40 s timeout), the patched build returns the moment the CLI exits. Warm-daemon
behavior is unchanged.
Suggested fix (open to alternatives)
Prepared branch:
https://github.com/Mat18115/dev-browser/tree/fix/windows-daemon-handle-inheritance
(single commit touching only cli/src/daemon.rs):
- an RAII guard that clears
HANDLE_FLAG_INHERIT on the client's std handles around
the daemon spawn and restores the previous flags afterwards (raw kernel32 FFI, no
new dependency);
- the
DETACHED_PROCESS creation flag as the Windows analogue of the existing Unix
setsid, so the daemon does not share (and die with) the client's console.
Alternative shapes that would also work if you prefer them:
PROC_THREAD_ATTRIBUTE_HANDLE_LIST via windows-sys (restrict inheritance to an
explicit list instead of toggling flags), or spawning the daemon through a broker
process. Happy to open the PR, rework it, or drop it entirely if you'd rather take a
different route.
Small aside found while testing
DEV_BROWSER_DAEMON currently appears unusable on Windows: fs::canonicalize
produces a \\?\-verbatim path and node's loader rejects it
(Cannot find module '\\?\C:\…'). This was hit while verifying the fix (using
DEV_BROWSER_DAEMON to point test builds at an existing daemon runtime) and worked
around it with a temporary local patch that is not part of the linked branch. Can be filed as a
separate issue if useful.
I use dev-browser on Windows as the execution engine of a browser-test harness, and
kept hitting piped invocations that never returned even though the CLI had long
exited.
Full disclosure: the debugging, the repro, and the suggested fix below were
done by Claude (Opus and some Fable), steered by me. I'm a structural engineer,
and not a SWE. I won't pretend to understand everything, but I try.
I've verified the repro myself. Happy to iterate on any of it, and no hard feelings
if you'd rather solve it differently, or not at all. Fable summary (edited) below:
Environment
mainSteps to reproduce
(or in Git Bash:
dev-browser status | cat)What happens
statusprints its output, then the pipeline hangs indefinitely — the shell neversees EOF even though the CLI process has exited. In other words, the output prints,
but the prompt never comes back; you have to Ctrl-C, or run
dev-browser stopfromanother terminal to release the pipeline. It only happens when the daemon is cold
(the client auto-spawns it); with a warm daemon (e.g. start with a non-piped
dev-browser statues) the same pipeline returns immediately.
In scripted use this bites hard: the first dev-browser invocation after a daemon
death wedges the surrounding pipeline (
… | tee log.txt, CI log capture, npmscripts). It was first noticed as a test-suite pipeline that stayed wedged for
24 minutes after its run had actually completed.
Root cause
spawn_daemon(cli/src/daemon.rs) nulls the daemon's stdio and callssetsidonUnix, but on Windows
CreateProcessstill runs with handle inheritance enabled(Rust's
std::processneeds it to pass the null stdio handles). That copiesevery inheritable handle of the client into the daemon — including the write end
of the stdout pipe the client holds when it runs inside a pipeline. The daemon is
long-lived by design, so that write end never closes and the shell waits forever.
Unix is immune: fds beyond stdio are CLOEXEC and the daemon detaches via
setsid.Evidence it is exactly this: clearing
HANDLE_FLAG_INHERITon just the client'sthree std handles for the duration of the spawn makes the repro pass. An A/B of two
debug builds of
mainon the same machine: the unpatched control hangs (killed by a40 s timeout), the patched build returns the moment the CLI exits. Warm-daemon
behavior is unchanged.
Suggested fix (open to alternatives)
Prepared branch:
https://github.com/Mat18115/dev-browser/tree/fix/windows-daemon-handle-inheritance(single commit touching only
cli/src/daemon.rs):HANDLE_FLAG_INHERITon the client's std handles aroundthe daemon spawn and restores the previous flags afterwards (raw kernel32 FFI, no
new dependency);
DETACHED_PROCESScreation flag as the Windows analogue of the existing Unixsetsid, so the daemon does not share (and die with) the client's console.Alternative shapes that would also work if you prefer them:
PROC_THREAD_ATTRIBUTE_HANDLE_LISTviawindows-sys(restrict inheritance to anexplicit list instead of toggling flags), or spawning the daemon through a broker
process. Happy to open the PR, rework it, or drop it entirely if you'd rather take a
different route.
Small aside found while testing
DEV_BROWSER_DAEMONcurrently appears unusable on Windows:fs::canonicalizeproduces a
\\?\-verbatim path and node's loader rejects it(
Cannot find module '\\?\C:\…'). This was hit while verifying the fix (usingDEV_BROWSER_DAEMON to point test builds at an existing daemon runtime) and worked
around it with a temporary local patch that is not part of the linked branch. Can be filed as a
separate issue if useful.