fix(dsh-plugin): settle runner promises when the child never emits close - #183
Conversation
`createBskRunner().run()` resolved only on the child's `close` event, and its timeout and abort paths merely killed the child and waited for that `close` to follow. Node emits `close` only once every stdio pipe has reached EOF, which needs every process holding a copy of the pipe handles to be gone, not just `bsk`. When `bsk` has to auto-spawn the daemon, the daemon can end up holding those handles: on Windows `CreateProcess` inherits every inheritable handle of the parent whatever the child's own stdio is set to, and the stdio handles `bsk` received from the plugin are inheritable, so the detached daemon keeps the plugin's pipes open long after `bsk` itself has exited. The tool call then never returns and the plugin's 120s `defaultTimeoutMs` never surfaces either, which is what issue Tencent#180 reports for `browser_session start`. Settle deterministically in every case: - After a normal `exit`, allow a short drain grace for `close` and then resolve with the output already captured, so `bsk session start` returns its JSON promptly even while the daemon it spawned holds the pipes. - After a kill we initiated (timeout or abort), resolve on `exit` at once; there is nothing left worth draining. - If a killed child reports nothing at all, resolve once the SIGKILL grace has passed, so the caller is always released. - A timeout or abort that lands after the process has already exited settles immediately rather than trying to kill a process that is gone. Once settled, stop collecting stdio: anything arriving later comes from whatever still holds the pipes, not from the finished command. `close` normally follows `exit` within the same event-loop turn, so the drain grace is only ever paid when something else is holding the pipes; the normal path is unchanged. The existing FakeChild emitted `close` on every kill, so the suite could not observe this. The new tests model a child whose `close` never arrives, including one that drives `browser_session start` through the real runner. Fixes Tencent#180
|
Thanks for the fix! I reproduced the issue on Windows and confirmed that this PR fixes the hang. The bounded fallback makes sense. Before merging, I’d like to address two remaining concerns:
Also, killFor() and killAll() still call killChild() directly, bypassing the new settlement deadline. It would be good to route those through the same bounded shutdown logic. I’m happy to keep the underlying Windows handle-inheritance fix as a separate follow-up. The original hang is confirmed fixed; the remaining concerns are about reliable cleanup and preserving complete output. |
Conflicts were in the runner and its tests, where main added the Windows stdin cancellation path. Resolved by keeping both: the settlement deadline that releases a caller when close never arrives now follows whichever kill grace applies, so a Windows cancellation that uses its full 15s is not cut short by the old 4s fallback.
Settling the promise left our ends of the stdio streams open, so whatever still held the other ends kept the host process alive after the run had finished. Close them and stop waiting on the child whenever a run settles, including the fallback paths and the spawn error path. Output that arrives after `exit` is not necessarily someone else's: it can be the child's own bytes, still buffered in the pipe. The drain window now reopens on every chunk instead of expiring on a fixed deadline, with a 2s cap so pipes that keep producing still settle the run. killAll() and killFor() called killChild() directly and never armed the settlement deadline, so a child that answered neither the signal nor the pipes left its caller waiting. Both now take the same bounded path as a timeout and an abort. Tests: a real host process running a child that exits while a detached grandchild holds the pipes, which has to settle and then exit on its own; a delayed chunked-output pair covering both sides of the drain boundary; and cleanup assertions on the fallback settles.
|
All three are in now, thanks for the careful read. Cleanup: every settle path drops the data listeners, destroys our ends of stdin/stdout/stderr and unrefs the child, so a grandchild holding the pipes can't keep the host alive. Output completeness: you were right that late bytes can be the child's own buffered output, so I've softened that comment. Each chunk now reopens the drain window instead of a fixed deadline, with a hard cap so it stays bounded if something keeps writing.
Tests: a real-process test that the host exits while a grandchild still holds the pipes, and a chunked-output test that exercises the drain window and the cap. |
Problem
browser_session { action: "start" }can hang forever even though thebskchild exits normally: the plugin'sclosehandler never runs, and the plugin's own 120 sdefaultTimeoutMsnever surfaces either, because the timeout path only kills the child and then keeps waiting for the sameclose(#180).Root cause
createBskRunner().run()resolves only on the child'scloseevent. Node emitscloseonly after every stdio pipe has reached EOF, and a pipe reaches EOF only when every process holding a copy of its write handle has closed it — not justbsk.When
bskhas to auto-spawn the daemon (ensure_daemon()→bsk daemon start→ detached daemon), the daemon can end up holding those handles. On Windows,CreateProcessinherits every inheritable handle of the parent regardless of what the child's own stdio is set to — Rust'sCommandcalls it withbInheritHandles = TRUE(the only way to change that is still unstable), and the stdio handles a child receives are themselves inheritable unless the child clears the flag, which Node does for itself via libuv'suv_disable_stdio_inheritance()and a Rust binary does not. So the daemon's own stdio isNULexactly asspawn_detached_atintends, but it also carries copies of the plugin's stdout/stderr pipe handles, and it lives on until its own idle timeout.bskprints its JSON and exits →exitfires →closenever does → the promise never settles. Killingbskon timeout changes nothing, becausebskis already gone.This fits the report: the same
bsk session start --jsonreturns in ~230 ms from pwsh, from a standalone Nodespawn, and from the host's own subprocess service, yet hangs from the plugin runner — the runner is the one caller that waits forclose. If the above is right, it hangs exactly on the call that had to spawn the daemon; a quick check on a Windows box isbsk daemon stop, thenbrowser_session startfrom the plugin (hangs), versus starting the daemon from a console first (returns).On macOS/Linux the same spawn does not leak:
Commandputs/dev/nullon fds 0–2 and everything else isO_CLOEXEC. I verified this here — the auto-spawned daemon has fds 0–2 on/dev/nulland no pipe fds inlsof— which is why the hang is Windows-only in practice, while the runner's dependence oncloseis platform-independent.Fix
Stop depending on
closealone and settle deterministically in every case (the reporter's suggested direction 3):exit: allow a short drain grace (250 ms) forclose, then resolve with the output already captured.closenormally followsexitin the same event-loop turn, so the grace is only ever paid when something else is still holding the pipes; the normal path is unchanged (measured below).exitimmediately — nothing left worth draining.KILL_GRACE_MS + 1 s), so the caller is always released.EXIT_DRAIN_GRACE_MS = 250is a judgment call; happy to change it.Tests
The existing
FakeChild.kill()emittedcloseon every kill, so the suite was structurally unable to see this. Seven tests added (181 → 188):runner.test.ts: settles on timeout / on abort whenclosenever fires; settles after the kill grace when neitherexitnorcloseever fires (fake timers, asserts the SIGINT → SIGKILL escalation); returns the captured output after a normalexitwhenclosenever fires; settles immediately on a timeout that lands afterexit; still waits forcloseon a normal exit so late stdout is not lost.tools.test.ts:browser_session startreturns the session through the real runner when the child emitsexitbut neverclose— the business path from [Bug][dsh-plugin] browser_session start hangs forever on Windows - bsk child exits fine, but plugin close event never fires; its own 120s timeout never surfaces #180.Six of the seven fail against the current
runner.ts(the seventh pins existing behaviour).Verification
pnpm lint(the CI chain: biome, stylelint, typecheck, vitest) — exit 0, 188/188.shchildren driven throughcreateBskRunner),main(47ac947) vs this branch:maincode: 0, stdout intacttimedOut: trueNot verified on Windows (no Windows machine here). The Windows mechanism above is derived from the Rust std and libuv sources; the fix itself does not depend on which process is holding the pipes.
Out of scope
The leak could also be closed at the source by restricting handle inheritance when
ensure_daemon()/spawn_detached_at()spawn on Windows (PROC_THREAD_ATTRIBUTE_HANDLE_LISTviaraw_attribute), so the daemon never receives the caller's pipe handles. I left that out: it is Windows-only code that CI (ubuntu) never compiles, and the runner needs to settle withoutcloseregardless.Closes #180