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. |
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