Skip to content

fix(windows): skip stale child process cleanup - #16850

Open
Krarilotus wants to merge 1 commit into
pingdotgg:mainfrom
Krarilotus:fix/windows-child-cleanup
Open

Krarilotus wants to merge 1 commit into
pingdotgg:mainfrom
Krarilotus:fix/windows-child-cleanup

Conversation

@Krarilotus

@Krarilotus Krarilotus commented Oct 7, 2026 •

Copy link
Copy Markdown

Windows commands that exit nonzero currently launch taskkill /T /F twice after Node has observed their exit: once from the Effect spawner's exit listener and once from scoped release. Expected Git probes such as a missing show-ref --verify --quiet therefore incur process creation and cleanup overhead despite already being complete.

Guard the shared Windows taskkill helper when exitCode or signalCode is non-null, completing its callback without launching a command. The owner is @effect/platform-node-shared, re-exported by @effect/platform-node and used by both T3's server and desktop. This uses T3's existing pinned-dependency patch mechanism rather than adding per-caller workarounds. The three changed files contain only the source/runtime patch, patch registration, and generated lockfile references; no dependency versions change and no tests or measurement scripts are added.

Credit: @Alb11747 already proposed this guard in #16007, targeting 4.0.0-rc.115. That PR is currently conflicted with main. This current-main implementation targets the pinned 4.0.1 and supplies an independent matched Windows reproduction. Maintainers may prefer to use this evidence to update the earlier PR.

This qualifies as the small focused obvious-bug exception in CONTRIBUTING.md: skip redundant cleanup of completed Windows children, preserve running-tree termination and all POSIX behavior, add no settings or polling-policy changes. Detached descendants that outlive their leader are not reclaimed by PID-based cleanup in either version. The guard avoids knowingly using a completed leader's stale PID; it does not eliminate the race if a live leader exits between the check and taskkill.

Validation on Windows 11, Ryzen 7800X3D / 16 logical threads, Node 24.19.0:

  • Disposable runtime experiment through the unchanged T3 ProcessRunner: completed exits 1/128 spawn 2 → 0 taskkill calls; exit 0 remains zero. Explicit kill after exit also stops launching stale cleanup. Live explicit kill, scope release, fiber interruption, and timeout all stop owned descendant heartbeats, with one taskkill instead of redundant post-exit calls. A detached/ignored-stdio orphan survives leader exit in both variants and is disposed by the experiment.

  • Matched ABBA measurement: two 22-second windows per variant, 20 one-second ticks, each issuing git rev-parse --is-inside-work-tree (exit 0) and git show-ref --verify --quiet refs/heads/absent (exit 1) in the same synthetic repository. Imports/warmup excluded. All 240 direct command processes were captured with retained native handles and GetProcessTimes; runner CPU uses process.cpuUsage. Means per window:

    Measure Baseline 611132c171f3 Fixed 35f6135456db
    Git invocations 40 40
    taskkill invocations 40 0
    Total direct command starts 80 40
    Summed scoped command wall time 8.073 s 4.535 s
    Runner + direct-command CPU 3.907 s 1.414 s

    This is a 63.8% reduction in measured direct workload CPU, not a whole-machine or installed-app comparison. WMI, Defender, conhost, secondary processes, and unrelated jobs are excluded; wall times vary with scheduling. No Git polling change is justified by this experiment. The installed app was not modified, restarted, or replaced.

  • Existing apps/server/src/processRunner.test.ts and apps/server/src/vcs/VcsProcess.test.ts: 43 passed, using an external minimal Vitest configuration with the patched published dependency. Selected existing vendored spawner checks for spawn options, unread stdout, nonzero exit, invalid command/cwd: 6 passed, 72 unselected. No new tests were written or committed.

  • pnpm install --lockfile-only --frozen-lockfile --ignore-scripts, git diff --check, and applying the patch against the exact published 4.0.1 package with git apply --check: passed. Strict source typechecking with repository-pinned @types/node@24.12.4 reports the same existing ExecException/ExecFileException callback mismatch on baseline and fixed source. No full application build, repo-wide checks, or POSIX runtime run.

Measured comparison

Per-run numbers and reproducible disposable experiment. Evidence is hosted in the fork as release assets; no PR-only assets are committed to the production tree. Only synthetic technical diagnostics are included, with no raw ETL or private application data.

Implementation and verification: GPT-6.1-Sol, high reasoning effort, through the Codex harness in a visible T3 Code agent thread.
Rebase update (2026-10-08): rebased onto upstream main a4c9494b0e36; the PR head is now 9fc073bd67a2. The production patch is identical (git range-diff), and the pinned Effect package, shared spawner, T3 command runner, and relevant existing tests are unchanged. Frozen-lockfile validation and patch application against the integrity-verified published @effect/platform-node-shared@4.0.1 passed again. The measurements and existing runtime checks above remain evidence from the original 611132c171f3 / 35f6135456db commits; they have not been relabeled or represented as a new benchmark. The rebase also incorporates the upstream review-policy change in #17018; no review policy is changed by this PR.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Oct 7, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at 35f6135

Macroscope's review found this PR approvable — This is a narrowly scoped Windows process-cleanup bug fix delivered as a pinned dependency patch. It preserves tree termination for live leaders and POSIX behavior, while the workspace and lockfile changes only make the patch reproducible; no product defaults or static-analysis diagnostics are changed.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-merge checks failed. Please resolve the failing checks before merging.

@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Path: .coderabbit.config.ts
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 481a0cd8-3906-4fe7-b1ce-0bd5576117e8
📥 Commits

Reviewing files that changed from the base of the PR and between 611132c and 35f6135.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • patches/@effect__platform-node-shared@4.0.1.patch
  • pnpm-workspace.yaml

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

Windows task cleanup now skips PID-based tree termination if the process leader has exited. The workspace applies the patch to @effect/platform-node-shared@4.0.1.

Changes

Windows task cleanup

Layer / File(s) Summary
Guard Windows task cleanup
patches/@effect__platform-node-shared@4.0.1.patch, pnpm-workspace.yaml
The JavaScript and TypeScript helpers call onExit(null) and return if exitCode or signalCode is non-null. Otherwise, they continue to invoke taskkill. The documentation describes the behavior when descendants outlive the leader. The workspace registers the patch for @effect/platform-node-shared@4.0.1.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Suggested reviewers: juliusmarminge

Merge Risk: ⚪ Minimal · up to 35f61

The Windows cleanup change is ready to merge after normal checks; no unresolved merge-blocking issue is established.

Architecture Summary

Architecture risk: 🔵 Low · up to 35f61

The change affects 2 systems.

Changed systems: patches, pnpm-workspace.yaml

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — patches (service) was modified; 1 changed file maps to changed impact.
  • observed — pnpm-workspace.yaml (service) was modified; 1 changed file maps to changed impact.

Before / after behavior

  • observed — Modified behavior in patches/@effect__platform-node-shared@4.0.1.patch: The generated JavaScript taskkill helper now calls onExit(null) and returns if exitCode or signalCode is non-null; otherwise, it invokes taskkill as before.
  • observed — Modified behavior in patches/@effect__platform-node-shared@4.0.1.patch: The Windows cleanup documentation now specifies that tree termination applies while the leader is running and that descendants outliving it are not reclaimed by PID-based cleanup.
  • observed — Modified behavior in patches/@effect__platform-node-shared@4.0.1.patch: The TypeScript taskkill helper now calls onExit(null) and returns if exitCode or signalCode is non-null; otherwise, it invokes taskkill as before.
  • observed — Modified behavior in pnpm-workspace.yaml: Adds the @effect/platform-node-shared@4.0.1 patch mapping under patchedDependencies.

Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore (reviewers only)

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Approvability ❌ Error This pull request patches the dependency @effect/platform-node-shared@4.0.1. The patch is added in patches/@effect__platform-node-shared@4.0.1.patch, and pnpm-workspace.yaml registers it under `… The pull request needs a maintainer's review.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing stale Windows child-process cleanup.
Description check ✅ Passed The description explains the problem, implementation, scope exception, affected dependency, verification steps, observed results, limitations, and rebase status. It does not use the template headings,…
Full details: Approvability

Explanation

This pull request patches the dependency @effect/platform-node-shared@4.0.1. The patch is added in patches/@effect__platform-node-shared@4.0.1.patch, and pnpm-workspace.yaml registers it under patchedDependencies. This matches the rule “Adds, upgrades, or patches a dependency.”

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@Krarilotus

Krarilotus commented Oct 8, 2026 •

Copy link
Copy Markdown
Author

Correction to my earlier policy acknowledgment: #17018 removed the custom Approvability check because “needs maintainer review” was misleadingly reported as requested changes. I verified the fresh upstream configuration and rebased this PR onto main (a4c9494b0e36); the new head is 9fc073bd67a2. CodeRabbit is reviewing the rebased head under the updated configuration. I have not dismissed any review or changed repository policy.

The patch remains identical, and the pinned Effect 4.0.1 spawner still contains the completed-child cleanup issue. That shared dependency owns Windows cleanup for both server and desktop, so its existing package-patch boundary remains the appropriate owner. Credit remains with the earlier guard proposed in #16007.

The linked matched evidence is from the original commits, not a new measurement: Git volume stays at 40 per window, taskkill starts fall from 40 to 0, and measured runner/direct-command CPU falls from 3.907 to 1.414 seconds, excluding whole-PC/WMI CPU. Live cancellation still terminates descendants; the detached-orphan limitation is unchanged. Frozen-lockfile validation and integrity-verified published-package patch application passed again after the rebase. No further production change is indicated by the reviews so far.

@Krarilotus
Krarilotus force-pushed the fix/windows-child-cleanup branch from 35f6135 to 9fc073b Compare October 8, 2026 13:12
@SunkenInTime

Copy link
Copy Markdown
Contributor

Tested this on Windows 11 (Ryzen 7 5800X, git 2.55, Node 24.13) and it fixes a real stall.

On the current nightly, a 90 s CPU profile of the desktop server showed the main thread blocked inside spawn() for 38.8 s. 106 of the 376 process launches in that window were these post-exit taskkill calls, costing 10 s of blocking. On Windows spawn() runs CreateProcess synchronously on the event loop, so every launch delays every WebSocket reply. When the stalls pass 5 to 10 s, clients miss the RPC ping and drop to "Connecting".

Benchmark: 60 runs of git config --get no.such.key (exit code 1) through NodeChildProcessSpawner from @effect/platform-node-shared 4.0.1, alternating an unpatched copy of the package with this patch:

run taskkill launches main thread blocked in launches wall time
unpatched 120 62.1 s 127.5 s
this PR 0 16.8 s 29.9 s
unpatched 120 36.9 s 72.4 s
this PR 0 2.5 s 10.7 s

The machine was at 60 to 70% CPU from other work, so absolute times are noisy. The launch counts aren't: every failing git call costs two extra taskkill launches without the patch and none with it.

The guard also matches what taskkill can actually do. Once the leader has exited, taskkill /PID <pid> /T can't find its old tree, and if Windows has given the PID to a new process, it kills that process's tree instead. Children that are still running keep the tree kill.

#16007 makes the same change against 4.0.0-rc.115, which main no longer uses.

Benchmark script
// Runs failing git commands through Effect's NodeChildProcessSpawner (as T3's git layer does)
// and counts taskkill launches plus main-thread time blocked inside spawn/execFile.
// usage: node --experimental-import-meta-resolve spawner-bench.mjs <repo root> <runs>
import cp from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';
import { pathToFileURL } from 'node:url';
const [root, runs = '60'] = process.argv.slice(2);
let taskkills = 0, blockedMs = 0, spawns = 0;
let depth = 0;
const timed = (name, fn) => function (...args) { if (depth > 0) return fn.apply(this, args); depth++; const s = performance.now(); try { return fn.apply(this, args); } finally { depth--; blockedMs += performance.now() - s; if (args[0] === "taskkill") taskkills++; else spawns++; } };
cp.execFile = timed('execFile', cp.execFile); cp.spawn = timed('spawn', cp.spawn); syncBuiltinESMExports();
const parent = pathToFileURL(root + '/apps/server/package.json').href;
const load = (s) => import(import.meta.resolve(s, parent));
const { Effect, Layer, Stream } = await load('effect');
const { ChildProcess, ChildProcessSpawner } = await load('effect/process').catch(() => load('effect/unstable/process'));
const NodeServices = await load('@effect/platform-node/NodeServices');
const program = Effect.gen(function* () {
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
  for (let i = 0; i < Number(runs); i++) {
    yield* Effect.scoped(Effect.gen(function* () {
      const child = yield* spawner.spawn(ChildProcess.make('git', ['config', '--get', 'no.such.key'], { cwd: root }));
      yield* Stream.runDrain(child.all);
      yield* child.exitCode;
    }));
  }
});
const t0 = performance.now();
const spawnerLayer = process.env.SPAWNER ? (await import(pathToFileURL(process.env.SPAWNER).href)).layer : null;
const layer = spawnerLayer ? Layer.merge(NodeServices.layer, spawnerLayer.pipe(Layer.provide(NodeServices.layer))) : NodeServices.layer;
await Effect.runPromise(program.pipe(Effect.provide(layer)));
await new Promise((r) => setTimeout(r, 3000)); // let fire-and-forget taskkills from exit handlers start
console.log(JSON.stringify({ spawner: process.env.SPAWNER ? "unpatched copy" : "installed", root, gitRuns: Number(runs), taskkillLaunches: taskkills, otherSpawns: spawns, mainThreadBlockedInLaunchMs: Math.round(blockedMs), wallMs: Math.round(performance.now() - t0) }));

Run from the repo root with node --experimental-import-meta-resolve spawner-bench.mjs <repo root> 60. Setting SPAWNER=<path to an unpatched dist/NodeChildProcessSpawner.js> loads that copy instead of the installed one.

@SunkenInTime

Copy link
Copy Markdown
Contributor

Rerun on a fresh boot of the same machine. The numbers in my comment above came from a machine with a kernel token leak that made every process launch slow, so their absolute times are inflated. Same benchmark, 60 runs of a failing git config through NodeChildProcessSpawner, alternating:

run taskkill launches main thread blocked in launches wall time per git command
unpatched 120 0.84 s 267 ms
this PR 0 0.16 s 29 ms
unpatched 120 0.85 s 273 ms
this PR 0 0.22 s 32 ms

Each failing git command takes about 9 times as long without the patch, because the scope's release step waits for taskkill to finish. In 60 seconds of the desktop server's normal work on this machine, 64 of its 168 process launches were these taskkill calls.

This branch has not been deployed

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

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants