Skip to content

fix(dsh-plugin): settle runner promises when the child never emits close - #183

Open
MaxFreedomPollard wants to merge 1 commit into
Tencent:mainfrom
MaxFreedomPollard:fix/dsh-plugin-runner-settles-without-close
Open

fix(dsh-plugin): settle runner promises when the child never emits close#183
MaxFreedomPollard wants to merge 1 commit into
Tencent:mainfrom
MaxFreedomPollard:fix/dsh-plugin-runner-settles-without-close

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown

Problem

browser_session { action: "start" } can hang forever even though the bsk child exits normally: the plugin's close handler never runs, and the plugin's own 120 s defaultTimeoutMs never surfaces either, because the timeout path only kills the child and then keeps waiting for the same close (#180).

Root cause

createBskRunner().run() resolves only on the child's close event. Node emits close only 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 just bsk.

When bsk has to auto-spawn the daemon (ensure_daemon()bsk daemon start → detached daemon), the daemon can end up holding those handles. On Windows, CreateProcess inherits every inheritable handle of the parent regardless of what the child's own stdio is set to — Rust's Command calls it with bInheritHandles = 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's uv_disable_stdio_inheritance() and a Rust binary does not. So the daemon's own stdio is NUL exactly as spawn_detached_at intends, but it also carries copies of the plugin's stdout/stderr pipe handles, and it lives on until its own idle timeout. bsk prints its JSON and exits → exit fires → close never does → the promise never settles. Killing bsk on timeout changes nothing, because bsk is already gone.

This fits the report: the same bsk session start --json returns in ~230 ms from pwsh, from a standalone Node spawn, and from the host's own subprocess service, yet hangs from the plugin runner — the runner is the one caller that waits for close. If the above is right, it hangs exactly on the call that had to spawn the daemon; a quick check on a Windows box is bsk daemon stop, then browser_session start from the plugin (hangs), versus starting the daemon from a console first (returns).

On macOS/Linux the same spawn does not leak: Command puts /dev/null on fds 0–2 and everything else is O_CLOEXEC. I verified this here — the auto-spawned daemon has fds 0–2 on /dev/null and no pipe fds in lsof — which is why the hang is Windows-only in practice, while the runner's dependence on close is platform-independent.

Fix

Stop depending on close alone and settle deterministically in every case (the reporter's suggested direction 3):

  • Normal exit: allow a short drain grace (250 ms) for close, then resolve with the output already captured. close normally follows exit in 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).
  • Kill we initiated (timeout / abort): resolve on exit immediately — nothing left worth draining.
  • Killed child that reports nothing at all: resolve once the SIGKILL grace has passed (KILL_GRACE_MS + 1 s), so the caller is always released.
  • Timeout / abort landing after the process already exited: settle at once instead of killing a process that is gone.
  • Once settled, detach the stdout/stderr collectors: anything arriving later belongs to whatever still holds the pipes, not to the finished command.

EXIT_DRAIN_GRACE_MS = 250 is a judgment call; happy to change it.

Tests

The existing FakeChild.kill() emitted close on every kill, so the suite was structurally unable to see this. Seven tests added (181 → 188):

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.
  • Real processes on macOS (sh children driven through createBskRunner), main (47ac947) vs this branch:
scenario main this branch
child exits 0, a grandchild keeps the pipes open 30 s hung (>6 s guard) settled in 254 ms, code: 0, stdout intact
child hangs, grandchild keeps the pipes, 300 ms timeout hung (>6 s guard) settled in 301 ms, timedOut: true
child exits 0, nothing holds the pipes 3 ms 3 ms
child hangs, nothing holds the pipes, 300 ms timeout 302 ms 302 ms

Not 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_LIST via raw_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 without close regardless.

Closes #180

`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
@iuyo5678

iuyo5678 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. Resource cleanup: finish() removes the data listeners, but does not close the remaining stdio streams. When another process keeps those pipes open, resolving the promise alone may leave live handles behind. Could you add explicit cleanup for the fallback paths and a real-process test verifying that the runner does not keep the host alive?

  2. Output completeness: After exit, the fixed 250ms deadline stops collecting output. Late data can still be buffered output from the original child, so the comment saying it must come from another process is too strong. Could you account for this and add a delayed, chunked-output test that exercises the deadline boundary?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants