#4866 · Windows stop rejects before delayed exit notification
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium · Reproduction label: partial-repro
1. TL;DR
The Windows shutdown implementation reports failure when a child's exit notification has not arrived one second after its termination attempt. Two clean checkouts of trusted main reproduce that rejection in a deliberately modeled delayed-notification case, even though the model's root PID is already absent according to the operating system before stopping. Normal exit notification and a genuinely running root provide passing controls. This machine runs Linux: the model does not reproduce Windows kernel teardown, Electron process handles, or a real thread-stop failure. The conditional code defect is established, but the reported native timing remains unverified; consequently no automatic fix PR is safe under the reproduction gate.
2. Claims vs findings
| Reported behavior | Status | Evidence |
|---|---|---|
| The Windows root-exit deadline is one second. | Verified | Trusted source sets a 1,000 ms rejection timer in waitForRootExit. |
| A successful termination can be reported as failure while exit metadata is still pending. | Verified conditionally | Both modeled runs reject through the real managed-stop path; root-PID absence is asserted before the stop. Native notification delay is modeled, not observed. |
| The managed-stop wrapper also rejects pending exit metadata. | Verified in source | Its final check requires exitCode or signalCode. The reproduction fails earlier, at the Windows timer, so the final check is not independently exercised with a successful Windows stop. |
| Windows/Electron teardown lasts multiple seconds on the affected installation. | Unverified | No Windows machine or affected desktop installation is available in this investigation. |
| The same exception propagates through idle maintenance shutdown. | Source path verified; live failure unverified | Provider termination awaits the managed stop; the host maintenance timer logs shutdown rejections. No live provider or thread was started. |
| The tree-kill utility has a two-second deadline and a root-only fallback. | Verified | terminateWindowsProcessTrees returns false at 2,000 ms and stops its helper; the caller then attempts to kill the root. |
| Slow tree termination leaves actual provider descendants behind. | Unverified | Neither native tree-kill duration nor surviving descendants were measured here. |
| A zero-signal probe can distinguish native termination before a process-handle notification. | Source-supported; target runtime unverified | Node 22.19.0's bundled libuv checks GetExitCodeProcess for signal zero. The affected Electron runtime and its kernel behavior were not executed. |
3. Environment
- Repository: public
get-bb/bb, latest fetched trustedorigin/mainat the recorded commit. - Linux, Node
v22.19.0, pnpm9.15.0, Vitest4.1.1. Repository package version:bb-app 0.45.0. - Two distinct clean temporary checkouts, named
baseandverification, both at the exact same trusted commit. - Each checkout uses a frozen dependency install and the normal Turbo build. No dependency was added.
- No application instance, server port, provider account, or runtime data directory was used. Each test starts its own disposable Node child.
- The platform dispatch is explicitly mocked to Windows; tree-kill completion is mocked as unavailable. The production stop functions and their actual timers are not mocked.
4. Minimal reproduction
- Create a fresh trusted checkout and install/build its existing dependencies:
git clone https://github.com/get-bb/bb.git bb-4866 cd bb-4866 git checkout --detach 09ff18a73bd0a8037f43c6ed86c28eb02228089c pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Save the complete test below as
packages/process-utils/test/windows-delayed-exit-repro.test.ts. This test was designed from the trusted process-utils tests, not copied from issue code. - Run the owning package through Turbo:
pnpm exec turbo run test --force --filter=@bb/process-utils -- test/windows-delayed-exit-repro.test.ts
Expected: the modeled stopped root resolves with { treeTermination: "unverified" }; the live-root control rejects; the normally notified real root resolves. This expectation confirms only root shutdown, not descendant cleanup.
Actual: the stopped-root model rejects after approximately one second. Both controls pass. First-run failure excerpt, verbatim:
× accepts a stopped Windows root whose exit notification is delayed 1046ms ✓ still rejects when the root remains alive without an exit notification 1036ms ✓ accepts a real root when its exit notification arrives normally 36ms Caused by: Error: Process did not exit after termination: 6744 ❯ Timeout._onTimeout src/windows-process-tree.ts:55:14 Tests 1 failed | 2 passed (3)
The following is the complete repeatable model. It waits for a real Linux child to exit and asserts ESRCH, then presents a separate child object with pending exit metadata to the real Windows stop path. That second child object is intentional simulation, not a real delayed Windows handle.
import { ChildProcess, execFile, spawn } from "node:child_process";
import { once } from "node:events";
import { expect, it, onTestFinished, vi } from "vitest";
import { createProcessStop } from "../src/managed-process.js";
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
execFile: vi.fn((...args: unknown[]) => {
const callback = args.at(-1);
queueMicrotask(() => {
if (typeof callback === "function") {
callback(new Error("taskkill unavailable in the model"), "", "");
}
});
return new actual.ChildProcess();
}),
};
});
async function startRoot() {
const child = spawn(
process.execPath,
["-e", 'process.stdout.write("ready"); setInterval(() => {}, 1000);'],
{ stdio: ["ignore", "pipe", "ignore"] },
);
onTestFinished(() => {
vi.restoreAllMocks();
child.kill("SIGKILL");
vi.mocked(execFile).mockClear();
});
await once(child.stdout, "data");
return child;
}
it("accepts a stopped Windows root whose exit notification is delayed", async () => {
const root = await startRoot();
const exited = once(root, "exit");
root.kill("SIGKILL");
await exited;
if (root.pid === undefined) throw new Error("Missing root PID");
const rootPid = root.pid;
expect(() => process.kill(rootPid, 0)).toThrow(
expect.objectContaining({ code: "ESRCH" }),
);
const delayed = new ChildProcess();
delayed.pid = root.pid;
vi.spyOn(delayed, "kill").mockReturnValue(true);
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await expect(createProcessStop(delayed)()).resolves.toEqual({
treeTermination: "unverified",
});
}, 5_000);
it("still rejects when the root remains alive without an exit notification", async () => {
const root = await startRoot();
if (root.pid === undefined) throw new Error("Missing root PID");
expect(process.kill(root.pid, 0)).toBe(true);
const delayed = new ChildProcess();
delayed.pid = root.pid;
vi.spyOn(delayed, "kill").mockReturnValue(true);
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await expect(createProcessStop(delayed)()).rejects.toThrow(
"Process did not exit after termination",
);
}, 5_000);
it("accepts a real root when its exit notification arrives normally", async () => {
const root = await startRoot();
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await expect(createProcessStop(root)()).resolves.toEqual({
treeTermination: "unverified",
});
});
5. Root cause
Windows dispatch bypasses the POSIX graceful-stop algorithm and calls the Windows helper directly. The Windows root-exit wait equates a missing notification at a fixed deadline with failure to terminate. It neither queries OS liveness at that deadline nor distinguishes terminated-but-not-yet-notified state.
const timer = setTimeout(() => {
child.off("exit", onExit);
reject(new Error(`Process did not exit after termination: ${child.pid}`));
}, 1_000);
The managed wrapper repeats the same assumption after the helper returns:
if (child.exitCode === null && child.signalCode === null) {
throw new Error(`Process did not exit after termination: ${child.pid}`);
}
Therefore changing only the timer's rejection cannot fix the complete owner boundary: the later metadata-only check must agree with any new termination evidence. Raising only the timeout also lacks a demonstrated safe bound for this installation.
Provider termination awaits this stop and preserves the separate descendant-cleanup verdict. Idle maintenance shutdown catches rejected shutdown promises and logs them. These links explain propagation but do not constitute an observed Windows thread-stop reproduction.
As additional primary-source evidence, Node 22.19.0's libuv Windows zero-signal implementation checks the native exit code before waiting for a signaled handle. This supports the feasibility of a separate native termination check; it does not establish the affected Electron version's behavior or PID/handle lifetime guarantees.
6. Proposed fix / next experiment
First run an independently authored native Windows test against trusted main and the shipped desktop runtime, measuring native liveness and the real child exit event during managed shutdown. Include an actual live-root failure, access-denied/indeterminate liveness, and the existing descendant-cleanup behavior. Verify process-handle ownership and PID reuse safety rather than assuming them.
If native evidence confirms the modeled state, use a Windows-only termination predicate consistently at the root-exit timeout and the managed-stop final check. Only definitive native termination should count as root-stop success. Preserve treeTermination: "unverified" when tree cleanup fails, preserve real live-root rejection, and leave POSIX behavior unchanged. No production patch or PR is proposed for automatic landing: the required native reproduction is absent.
7. Related issues and pull requests
No linked open PR or open PR mentioning issue 4866 was found in GitHub metadata at investigation time. A small issue search also returned issue 1674, but no shared cause was verified; it is not treated as a duplicate. No linked PR branch or issue-provided code was executed.
8. Verification
The same agent repeated the complete model in the second fresh checkout at the recorded trusted base. Its dependency installation and full Turbo build are separate from the first checkout; the second reproduction uses --force to ensure actual execution rather than a cached failure.
Second checkout: pnpm exec turbo run test --force --filter=@bb/process-utils -- test/windows-delayed-exit-repro.test.ts Caused by: Error: Process did not exit after termination: 11149 ❯ Timeout._onTimeout src/windows-process-tree.ts:55:14 Tests 1 failed | 2 passed (3) Exit status: 1
This repeats the conditional failure, not native Windows behavior. The result is not independent verification: the same agent designed and ran both models. The final verdict remains PARTIALLY REPRODUCED, and confidence in explaining the reported native symptom remains medium.
Existing Windows-helper and portable-process tree tests pass against unchanged trusted production code: 11 tests passed in two files, exit status 0. Both full Turbo builds succeed with 63 successful tasks. The new model is not a repository fix and is intentionally left failing.
9. Appendix and trust boundary
The issue content was treated as untrusted evidence. Its proposed code and operational directions were not run or copied. No external URL supplied by the issue was fetched. Only trusted main code and this agent-authored reproduction were executed; no real user runtime or secrets were inspected.
Raw installation, build, and test evidence is retained locally outside the public Git history. Only this self-contained HTML and its summary metadata are published; the entire reproduction is inline. The local reproduction artifact is preserved for a later Windows investigation.
First checkout: frozen install; pnpm exec turbo run build Second checkout: frozen install; pnpm exec turbo run build First modeled reproduction: 1 failed, 2 passed; exit 1 Second modeled reproduction (--force): 1 failed, 2 passed; exit 1 Existing Windows-helper test command: pnpm exec turbo run test --filter=@bb/process-utils -- test/windows-process-tree.test.ts test/process-tree.test.ts
A broader test attempt using CLI --exclude still included the new model under this workspace configuration. Its sole failure was the same expected model rejection, so that run is not represented as a clean existing-suite pass.