Skip to content

[Bug] Windows: the auto-spawned daemon dies with the launching process tree (daemon start / status), and read-only commands start it as a side effect #268

Closed

Description

@QIUZAIYOU

Environment

  • OS: Windows 10 22H2 (19045), x64
  • bsk CLI / daemon: 0.2.1 (daemon protocol 1.1), ~/.local/bin/bsk.exe, BSK_HOME=~/.bsk
  • Browser extension: ext-v0.3.0 (reports protocol 1.3)
  • Host: DeepSeek Harness Desktop. Shell commands are invoked with a fixed per-call timeout, stdout captured through a pipe (no TTY); on timeout the harness kills the command's whole process tree.
  • Plugin involved downstream: @wxg-prc-cpg/browser-skill-dsh-plugin 0.2.1

Actual behavior

bsk daemon start never returns in that context. When the harness kills the command at its 120 s timeout, the daemon dies with it — even though bsk daemon start --help says "Start the daemon (auto-detaches unless --foreground)".

bsk status behaves the same way, and it starts a daemon as a side effect: run while no daemon was alive, it printed a complete status block (daemon version 0.2.1, pid 25900, uptime 0s) and then hung; the harness killed it 90 s later — after which that daemon never logged again either.

So a caller that merely checks status can silently remove a healthy daemon. In agent harnesses that is a footgun rather than an edge case, because status / doctor are the first commands an agent runs (#265 and #24 report the unbounded wait; this is the side-effect half of it). The result: an unrelated later browser_session start has to auto-spawn a daemon, hits #180 / #183, hangs forever, and leaves the orphaned Agent Window of #245.

Evidence (~/.bsk/daemon.log.2026-09-17, UTC; local = UTC+8)

Only lines that are actually in the log:

01:12:36  daemon lock acquired; ws server listening 127.0.0.1:52800; daemon ready pid=27604
01:12:36  browser connected id=<ext-instance>
01:12:41  periodic update check failed (github.com unreachable)
01:15:11  daemon ready pid=25900
01:15:11  browser connected id=<ext-instance>
01:17:04  daemon ready pid=18152
01:17:07  browser connected id=<ext-instance>
01:22:34  idle session stopped session=cgya

How to read it (inferences, marked as such):

No daemon exit is logged (no shutdown line, no reason), which is what makes this hard to diagnose from the outside.

Expected behavior (suggested)

  1. Either detach for real, or refuse. If the spawned daemon cannot survive the launcher (DETACHED_PROCESS / CREATE_NEW_PROCESS_GROUP, and on Windows CREATE_BREAKAWAY_FROM_JOB so a job-object kill of the caller cannot take it down), daemon start should fail fast with a message pointing at bsk daemon start --foreground (the pattern docs: clarify remote setup and persistent daemon startup #263 documents for persistent host tasks). The current middle ground — appears detached, then dies silently — is the worst case: the caller has no way to notice.
  2. daemon start should be idempotent and return immediately when a healthy daemon is already running; today it blocks in agent contexts even though the daemon is up.
  3. Read-only commands should not auto-start a daemon (status, doctor, browsers), or should behave as BSK_AUTO_START=0 when stdout is not a TTY. Starting a service as a side effect of a query is what makes agents kill daemons by accident.
  4. Log a reason when the daemon exits (parent-killed / signal / idle). Diagnosing this from timestamps alone cost me a while; a single line would have made it obvious.

Related

Workaround (verified on 0.2.1)

Let the extension/plugin start the daemon (its spawner exits normally, so the daemon survives as an orphan), or start it fully detached:

Start-Process -FilePath "$env:USERPROFILE\.local\bin\bsk.exe" -ArgumentList 'daemon','start' -WindowStyle Hidden

Do not run bsk daemon start / bsk status from a shell tool that kills its process tree on timeout. Read-only liveness probing that never starts anything: bsk browsers, or the owner of TCP port 52800.

Activity

  1. aron-intframe commented on Sep 17, 2026

    @aron-intframe

    Reproduced the "never returns" half, and it explains the "dies with the launcher" half too. Cause: the auto-detached daemon inherits the launcher's stdout pipe handle, so the harness never sees EOF on the command; it waits until its timeout, and the tree kill it does at that point takes the daemon with it.

    spawn_detached_at on Windows (crates/bsk-cli/src/daemon/start.rs) uses std::process::Command with Stdio::null() and DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. That only sets the child's own std handles to NUL. Rust's Command still calls CreateProcessW with bInheritHandles = TRUE (std sys/process/windows.rs, inherit_handles: true by default; the inherit_handles(false) extension is unstable, rust-lang/rust#146407), so every inheritable handle in the launcher is copied into the daemon. The launcher's stdout, when a harness gave it a pipe, is exactly such a handle. The daemon holds the write end for its whole life, and the harness reading to EOF blocks. No breakaway flag is set either, so a job-object or taskkill /T tree kill from the harness reaches the daemon (DETACHED_PROCESS changes neither the parent PID nor job membership).

    Evidence: release MSVC binaries run under Wine (which applies the same CreateProcess handle-inheritance rules), harness simulated with Python Popen(stdout=PIPE) + communicate(timeout=...), wineserver/services started beforehand so only handles passed on by the launcher count, BSK_AUTO_UPDATE=off, BSK_HOME set to a scratch dir:

    ### bsk 0.2.1
    == control ==
    $ bsk --version   -> stdout EOF after 0.1s, exit=0
    == daemon start (auto-detach) ==
    $ bsk daemon start   -> NO stdout EOF after 30s; launcher bsk.exe already exited rc=0
        processes still holding our stdout pipe:
          pid 3351320: /usr/lib/wine/wineserver64 -p0 -p
          pid 3352234: Z:\...\win021\bsk.exe daemon start
    == status while daemon is up ==
    $ bsk status   -> stdout EOF after 5.1s, exit=0
    == daemon stop ==
    $ bsk daemon stop   -> stdout EOF after 0.2s, exit=0
    == status with no daemon running (auto-start) ==
    $ bsk status   -> NO stdout EOF after 30s; launcher bsk.exe already exited rc=0
        processes still holding our stdout pipe:
          pid 3362562: Z:\...\win021\bsk.exe daemon start
    

    Same on 0.3.0: $ bsk daemon start -> NO stdout EOF after 30s; launcher bsk.exe already exited rc=0, holder = the bsk.exe daemon start child. (wineserver shows up because it backs every Windows handle; the holder that matters is the daemon.) Note that in every hanging case the launcher had already exited with rc=0: the daemon came up and daemon start itself returned within seconds. Only the pipe keeps the harness waiting, which matches your log (daemon ready at 01:12:36, command "hung" for 120 s, then the kill).

    Fix, stable Rust, windows-sys is already a dependency (add the Win32_System_Console feature for GetStdHandle): clear the inherit bit on the launcher's own std handles before spawning. The child gets NUL for its stdio anyway, so nothing is lost:

    #[cfg(windows)]
    fn stop_std_handle_inheritance() {
        use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation};
        use windows_sys::Win32::System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE};
        for id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = unsafe { GetStdHandle(id) };
            if !h.is_null() && h != INVALID_HANDLE_VALUE {
                unsafe { SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0) };
            }
        }
    }
    // spawn_detached_at (windows), right before cmd.spawn():
    stop_std_handle_inheritance();

    The complete version is PROC_THREAD_ATTRIBUTE_HANDLE_LIST with an empty list (also covers any other inheritable handle the launcher has open, e.g. a log file or socket), but that means a raw CreateProcessW call, since std's raw_attribute is unstable as well. For the lifetime half: add CREATE_BREAKAWAY_FROM_JOB to the creation flags, retry without it on ERROR_ACCESS_DENIED (job forbids breakaway), and when the retry path is taken print that the daemon will not survive a tree kill and point at --foreground. Once the pipe leak is gone, daemon start and status return in seconds, so a harness like yours never reaches the timeout that kills the tree in the first place.

    On your point 3: 0.3.0 has BSK_AUTO_START=0, which makes read-only commands refuse instead of spawning. Verified: BSK_AUTO_START=0 bsk status with no daemon -> stdout EOF after 0.1s, exit=2, "automatic daemon startup is disabled (BSK_AUTO_START=0)". It is opt-in though, not the default when stdout is not a TTY. Until the fix lands, that plus your Start-Process workaround (which hands the daemon no pipe) is the safe combination.

    We hit the harness side of this ourselves (a wait that never unblocks although the job already finished, liveness judged from log staleness rather than process presence); the check we use for it is in our repo (ours): https://github.com/aron-intframe/agent-guards

  2. blacksamuraiiii commented on Sep 17, 2026

    @blacksamuraiiii

    Independent reproduction of both behaviors from a different calling context — confirming this is not harness-specific.

    Environment

    • OS: Windows 11 host, bsk 0.2.1 (daemon protocol 1.1), Edge extension 0.3.0 (protocol 1.3)
    • Caller: WSL2 (Debian) → powershell.exe interop — each bsk invocation is a fresh non-interactive PowerShell process with stdout piped, no TTY, no process-tree kill by default (unlike the DeepSeek Harness in the OP). Daemon was fully stopped and verified gone (Get-Process bsk* → 0) before each test.

    Reproduction 1: status hangs indefinitely with no daemon

    $env:BSK_AUTO_START = '0'
    bsk status --json

    Result: hangs >90 s (killed by WSL-side timeout). Same hang with plain bsk status --json. Observed repeatedly (3/3 attempts, 60–90 s kills).

    Per the docs (docs/sandboxed-agents.md), BSK_AUTO_START=0 should make status "report the problem and ask for host-side startup. It does not spawn a replacement" — neither happened: it neither reported nor exited.

    Reproduction 2: implicit daemon spawn despite BSK_AUTO_START=0

    After one of the killed status runs, Get-Process bsk* showed a newly spawned daemon (fresh pid, uptime ≈ 100 s) — i.e. the status command spawned a daemon as a side effect even with BSK_AUTO_START='0' explicitly set, and that daemon kept running after the killed CLI process. status after that returned a healthy JSON block with the new pid.

    Same footgun as the OP describes, reproduced without any harness process-tree semantics: the CLI process was killed client-side by WSL timeout, and the spawned daemon survived independently.

    Suggestion

    Echoing the OP: read-only commands (status, doctor) should never start a daemon, and with BSK_AUTO_START=0 they must fail fast with a clear "daemon not running" message instead of blocking. Additionally the hang may be related to stdout pipe semantics in non-TTY contexts — a fast-fail would fix both.

    Happy to provide ~/.bsk/daemon.log excerpts if useful.

  3. iuyo5678 commented on Sep 20, 2026

    @iuyo5678
    Collaborator

    Fixed by #282, now merged into main. Background daemons no longer inherit caller pipe handles, and Windows startup verifies Job breakaway or fails with setup guidance. Launcher timeouts no longer terminate a shared daemon. Regression tests, including first-call status, pass in CI.
    Query commands retain their existing auto-start defaults; BSK_AUTO_START=0disables implicit startup. Changing those defaults can be tracked separately.
    Closing this issue as fixed.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions