Skip to content

Silent process termination after long-running child_process.spawn() on Windows (v24.13.0, works on v22.22.1) #62125

Closed as not planned

Description

@tsunosekai

Node.js v24.13.0: Silent process crash after long-running child_process operations on Windows

Summary

Node.js v24.13.0 on Windows 11 silently terminates (no error, no exit handler, no diagnostic report) after ~25 minutes of child process execution. The crash occurs consistently at a specific point in the program flow. Downgrading to Node.js v22.22.1 LTS completely resolves the issue with no code changes.

Environment

  • Node.js: v24.13.0 (crashes) / v22.22.1 (works)
  • OS: Windows 11 Home 10.0.22621 (x64)
  • CPU: AMD Ryzen 5 5600G (AMD64 Family 25 Model 33)
  • RAM: 32 GB
  • Shell: Git Bash (MINGW64)
  • Node version manager: nvm-windows

Reproduction scenario

The application is a CLI tool that distributes tasks to remote cloud servers via SSH:

  1. Phase 1: Connects to a remote server via ssh2 (npm) and uploads files via SFTP
  2. Phase 2: Disconnects ssh2, then spawns a system ssh child process (child_process.spawn) to run a long-running remote command (~25 minutes)
  3. Phase 3: Spawns another ssh + tar child process pair (piped) to download results
  4. Phase 4: Cleans up remote files via another spawned ssh process

The crash occurs at the transition from Phase 2 to Phase 3 — after the long-running child process completes and before (or during) the next spawn() call.

Crash characteristics

The crash is not a JavaScript-level error. All of the following were in place, and none of them fired:

// All registered — none triggered on crash
process.on('uncaughtException', handler);   // ✗ not called
process.on('unhandledRejection', handler);  // ✗ not called
process.on('exit', handler);                // ✗ not called
process.on('SIGTERM', handler);             // ✗ not called
process.on('SIGHUP', handler);              // ✗ not called
process.on('SIGBREAK', handler);            // ✗ not called

// Diagnostic reports enabled — no report generated
process.report.reportOnFatalError = true;   // ✗ no report file
process.report.reportOnSignal = true;       // ✗ no report file

Additionally:

  • No Windows Event Log entries related to the crash
  • No crash dump files generated
  • No stderr output at the moment of crash
  • The process simply vanishes — the terminal returns to the prompt with no message

Debugging performed

1. Synchronous breadcrumb logging

Added fs.appendFileSync() calls before and after every operation to trace the exact crash point. This confirmed the crash happens after Phase 2 completes and before Phase 3's spawn() returns.

Example breadcrumb log (last entries before crash on v24):

9:06:32 [worker-1] _sshExec: completed with code 0    ← Phase 2 done
9:06:32 [worker-1] GC complete before Phase 3
9:06:32 [worker-1] Phase 3 START (heapUsed=24MB, rss=99MB)
9:06:32 [worker-1] Phase 3a: before rmSync              ← CRASH HERE

2. Memory analysis (ruled out OOM)

At the moment of crash:

  • heapUsed: 24 MB
  • rss: 99 MB
  • System free memory: ~9 GB (of 32 GB total)

OOM is definitively ruled out.

3. ssh2 library ruled out

Initially suspected the ssh2 npm library's native crypto bindings. However:

  • ssh2 was disconnected and set to null 29 minutes before the crash
  • The native crypto build directory was removed via postinstall script (JS fallback only)
  • global.gc() was explicitly called twice after disconnecting ssh2
  • The crash still occurred even with ssh2 completely out of the picture

4. Systematic elimination

Hypothesis Test Result
ssh2 native crypto Removed native build, used JS fallback Still crashes
ssh2 connection state Disconnected ssh2 29 min before crash Still crashes
OOM Measured: 24MB heap, 99MB rss Not OOM
System memory 9GB free of 32GB Not system OOM
SFTP download Replaced with system ssh+tar pipe Still crashes
rmSync with Unicode paths Removed rmSync before crash point Still crashes
GC timing Explicit global.gc() before Phase 3 Still crashes
Event loop starvation Added 50ms yield before Phase 3 Still crashes
Node.js v24.13.0 Switched to v22.22.1 Fixed

5. Successful run on v22.22.1

With identical code (no changes between test runs other than the Node.js version):

=== Summary ===
Completed: 3/3
Elapsed: 57.4 min
Process exiting with code 0

All three tasks completed, including two that involve ~25 minutes of child process execution followed by download. The crash never occurred.

Minimal reproduction pattern

While I haven't created a minimal reproducer, the pattern is:

  1. Run on Windows 11 with Node.js v24.13.0
  2. Use child_process.spawn() to run a long-lived process (~25 min)
  3. After the spawned process exits, immediately spawn new processes
  4. The process silently terminates at or around the second spawn() call

The application uses:

  • child_process.spawn() for SSH and tar processes
  • stream.pipe() between spawned processes (ssh stdout → tar stdin)
  • setInterval (1s dashboard timer running throughout)
  • --expose-gc flag with explicit global.gc() calls
  • ESM modules ("type": "module")

Possibly related

  • CVE-2025-59466: async_hooks stack overflow causing unrecoverable process termination. Fixed in v24.13.0, but the symptoms (process death bypassing all error handlers) are identical. This application does not explicitly use async_hooks, but dependencies or Node.js internals may.
  • The crash only manifests after long-running operations (~25 min), suggesting a time-dependent or GC-cycle-dependent trigger.

Dependencies

{
  "dotenv": "^16.5.0",
  "js-yaml": "^4.1.0",
  "ssh2": "^1.17.0",
  "yargs": "^17.7.2"
}

Note: ssh2's native crypto build is removed via postinstall (rm -rf node_modules/ssh2/lib/protocol/crypto/build), forcing the pure JS fallback.

Expected behavior

The process should either:

  1. Continue running normally, or
  2. If an error occurs, trigger uncaughtException/unhandledRejection/exit handlers and produce a diagnostic report

Actual behavior

The process silently vanishes with no output, no error handlers triggered, and no diagnostic report generated.

Workaround

Use Node.js v22 LTS instead of v24.

Activity

  1. tsunosekai commented on Mar 6, 2026

    @tsunosekai
    Author

    Additional clue: --expose-gc + explicit global.gc() may be the trigger

    Update: This hypothesis was disproven — see the next comment. --expose-gc is NOT the trigger. The crash also occurs without --expose-gc and without any global.gc() calls.


    Original comment (kept for investigation history)

    We have a nearly identical application that performs the same workload (SSH into remote server, run ~25 min task, download results) but does not crash on v24.13.0.

    Key differences between the crashing app and the non-crashing app:

    App A (crashes) App B (no crash)
    --expose-gc flag Yes No
    Explicit global.gc() calls Yes (called after disconnecting SSH library) No
    SSH library lifecycle Connect → disconnect → 25 min idle → reconnect Stays connected throughout
    Entry point Complex (exit handlers, process.report, signal handlers) Simple (await cli.parse())

    The crashing app calls global.gc() twice right after disconnecting the ssh2 library (which uses Node.js built-in crypto module internally), then idles for ~25 minutes while a child process runs, then crashes when it tries to spawn() again.

    The non-crashing app keeps the ssh2 connection alive throughout and never calls global.gc().

  2. tsunosekai commented on Mar 6, 2026

    @tsunosekai
    Author

    Update: --expose-gc is NOT the trigger

    Ran the same application on v24.13.0 without --expose-gc and without any global.gc() calls. The crash still occurs.

    Furthermore, the crash also occurs when all remote tasks complete in seconds (output already existed, so all steps were skipped). This rules out the long-idle-period hypothesis.

    New observation: the crash consistently happens after processing a task whose directory name contains non-ASCII (Japanese) characters. A task with an ASCII-only name always succeeds.

    The crash sequence:

    1. Task with ASCII-only directory name — upload, exec, download, cleanup: success
    2. Task with Japanese directory name (e.g. テスト_001) — upload, exec, download complete → crash before starting task 3

    The download uses child_process.spawn('ssh', ...) piped to spawn('tar', ['xf', '-', '-C', localDir]) where localDir contains Unicode characters. The tar process completes successfully (log shows "ssh+tar download complete"), but the process dies shortly after.

    This may be a Unicode path handling bug in child_process.spawn() or fs operations on Windows in v24.13.0.

    Test conditions:

    • v24.13.0, no --expose-gc, no global.gc() calls
    • All remote tasks skipped (completed in seconds, no long idle)
    • Same code works on v22.22.1
  3. Renegade334 commented on Mar 8, 2026

    @Renegade334
    Member

    The application is a CLI tool that distributes tasks to remote cloud servers via SSH:

    1. **Phase 1**: Connects to a remote server via ssh2 (npm) and uploads files via SFTP
    
    2. **Phase 2**: Disconnects ssh2, then spawns a system `ssh` child process (`child_process.spawn`) to run a long-running remote command (~25 minutes)
    
    3. **Phase 3**: Spawns another `ssh` + `tar` child process pair (piped) to download results
    
    4. **Phase 4**: Cleans up remote files via another spawned `ssh` process
    

    Could you provide an example code reproduction?

  4. DrDexter6000 commented on Apr 6, 2026

    @DrDexter6000

    Additional Independent Verification: Minimal Reproduction with Detached Process (~4-6 seconds)

    We independently encountered and diagnosed this exact issue while debugging a Chrome Native Messaging host chain for a browser automation tool. Our findings strongly corroborate the original report and provide a significantly tighter minimal reproduction window.

    Environment

    • Node.js: v24.13.1 (crashes) vs v22.22.2 LTS (works)
    • OS: Windows 11
    • Context: Detached child processes spawned via child_process.spawn from within a Chrome Native Messaging host (native-host.cjs)

    Key Finding: Crash in 4-6 Seconds, Not 25 Minutes

    Unlike the original report where the crash occurred after ~25 minutes of complex I/O (SSH, SFTP, tar), we isolated the issue to occur within 4-6 seconds using a completely bare script with zero I/O, zero external modules, and zero business logic.


    Minimal Reproduction

    worker.js — bare event loop, nothing else:

    // No I/O, no GC calls, no external modules, no business logic
    setInterval(() => {}, 1000);

    host.js — spawns worker as a detached process:

    const { spawn } = require('child_process');
    const child = spawn(process.execPath, ['worker.js'], {
        detached: true,
        stdio: 'ignore',
        windowsHide: true
    });
    child.unref();

    Result on v24.13.1: worker.js silently terminates via TerminateProcess() after ~4-6 seconds.
    Result on v22.22.2: worker.js survives indefinitely (tested 137+ seconds, manually stopped).


    Observations

    1. No Exit Handlers Fire: We attached all available handlers — none triggered before the process vanished:

      process.on('exit', () => log('exit'));
      process.on('SIGTERM', () => log('SIGTERM'));
      process.on('uncaughtException', () => log('uncaughtException'));
      // None of these print. The process simply disappears.
    2. Wrapper Independence: Wrapping the spawn in cmd.exe /c start or powershell.exe did not prevent termination. Renaming node.exe to a custom binary name also had no effect.

    3. Digital Signature Verified: node.exe has a valid digital signature (OpenJS Foundation, Microsoft-verified), ruling out AV/trust policy as the cause.

    4. Python Baseline: An equivalent Python time.sleep() loop spawned as a detached process survives indefinitely under identical conditions, confirming this is Node.js-specific.

    5. Version-Specific:

      Node.js Version Detached Process Lifetime
      v24.13.1 ~4-6 seconds ❌
      v22.22.2 LTS 137+ seconds ✅

    Impact in Our Use Case

    This single bug explained all observed symptoms across 29 prior diagnostic tasks:

    • Broker process "churn" every ~6-7 seconds
    • EPIPE errors when the plugin wrote to a dead broker pipe
    • Request timeouts for long-running browser tool calls
    • Missing process.on('exit') traces (handlers never fired because TerminateProcess() bypasses JS cleanup)

    What appeared to be a complex multi-layer failure in the Chrome extension ↔ native-host ↔ broker ↔ named-pipe chain was entirely caused by this single runtime regression.


    Workaround (Verified)

    Downloading node.exe from the v22.22.2 LTS release and using it specifically to spawn the broker process resolved the issue completely:

    // native-host.cjs — use v22 binary for broker spawn
    const nodeV22Path = path.join(path.dirname(BROKER_PATH), 'node-v22.exe');
    const brokerNodeExe = fs.existsSync(nodeV22Path) ? nodeV22Path : process.execPath;
    
    spawn(brokerNodeExe, [BROKER_PATH], {
        detached: true,
        stdio: 'ignore',
        windowsHide: true
    });

    After this change, the broker process (running on v22.22.2) survived 137+ seconds with no crashes, and all browser automation tools (browser_get_tabs, browser_navigate, browser_snapshot) worked correctly.


    Hope this additional data point — especially the minimal reproduction with a bare setInterval — helps narrow down the root cause in the runtime layer.

  5. DrDexter6000 commented on Apr 6, 2026

    @DrDexter6000

    Update: Windows Security Behavioral Detection Confirmed as Contributing Factor

    After further investigation, we now have direct evidence that Windows Security (Defender) behavioral detection is killing detached child processes on Windows. This may be a contributing factor alongside any Node.js v24 runtime behavior.

    Evidence

    On 2026-04-06 at 08:23 local time, while running minimal detached spawn reproduction tests with Node.js v24.13.1 on Windows 11, Windows Security flagged and terminated the test script:

    • Detection: Trojan:Win32/SuspExec.SE (Suspicious Execution)
    • Status: Removed (process killed)
    • Action: The process was terminated immediately with no notification to the Node.js runtime

    The Windows Security Protection History shows the exact command line that was flagged. The test script was a minimal reproduction: spawn a detached node -e 'setInterval(()=>{},1000)' child, then poll it with process.kill(pid, 0).

    Why This Pattern Triggers Detection

    Windows Security behavioral analysis flags this pattern because it resembles C2 beacon/persistence behavior:

    1. PowerShell writes a script to disk
    2. Script spawns a detached, never-exiting child process
    3. Parent polls child liveness at regular intervals
    4. Child has no visible I/O or user-facing activity

    This is indistinguishable from malware persistence techniques at the behavioral level.

    Key Observation: Other Tools Work Fine

    On the same machine, other browser automation tools work without issues:

    • Claude Code (desktop): browser automation works perfectly
    • Manus AI (web): browser automation works perfectly

    This suggests the issue is not purely a Node.js v24 runtime bug. If it were, all Node.js-based tools would be equally affected.

    Revised Hypothesis

    The root cause is likely a combination of factors:

    1. Windows Security behavioral detection (confirmed): kills processes that match the SuspExec pattern, regardless of digital signature validity. The detection is behavior-based, not signature-based.
    2. Node.js v24 compatibility: Windows Security may not have whitelisted/recognized the behavioral profile of Node.js v24 yet (newer runtime = fewer established behavioral patterns = higher false positive rate).
    3. Spawn pattern similarity to malware: detached: true + stdio: 'ignore' + child.unref() + polling is exactly how many persistence implants work.

    Why Node v22 LTS Works

    Node v22 LTS has been around longer. Windows Security has likely built behavioral whitelisting for common Node.js v22 spawn patterns. Node v24 is newer and its behavioral profile may not be fully recognized yet.

    Updated Workaround

    In addition to using Node v22 LTS, also consider:

    1. Add your project directory to Windows Security exclusions (Settings > Privacy & Security > Windows Security > Virus & Threat Protection > Exclusions)
    2. Use Node v22 LTS for spawning long-lived detached processes: nvm install 22 && nvm use 22
    3. If developing/testing detached spawn patterns on Windows, be aware that Windows Security real-time protection may silently kill your processes

    This finding also explains the "invisible hand" effect we observed: processes dying after ~4-6 seconds with zero diagnostic output, zero JS exit handlers firing, and zero Event Log entries. Windows Security TerminateProcess() produces exactly this symptom profile.

  6. DrDexter6000 commented on Apr 6, 2026

    @DrDexter6000

    ⚠️ Correction: Root Cause Was Windows Defender, Not Node v24 Bug

    TL;DR: Our original diagnosis was wrong. The actual root cause was Windows Defender behavior detection (Trojan:Win32/SuspExec.SE), not Node.js v24 silently terminating detached processes.

    What Changed

    After adding Windows Defender exclusions for OpenCode.exe, opencode-cli.exe, node.exe, and their install directories:

    • Node v24.13.1 detached spawn: ✅ Stable 8+ seconds (previously died at 4-6s)
    • Chrome headless launch: ✅ Normal (previously killed by Defender)
    • CDP browser automation (full chain): ✅ Tab create → navigate → JS eval → DOM modify → close — all pass

    The same Node v24.13.1 binary, same code, same machine — the only change was Defender exclusions.

    Corrected Diagnosis

    Before (Wrong) After (Correct)
    Node v24 silently terminates detached children via TerminateProcess() Windows Defender behavior detection flags AI coding agent processes as Trojan:Win32/SuspExec.SE and kills them
    Workaround: downgrade to Node v22 Workaround: add Defender exclusions for AI coding tools
    #62125 is the root cause #62125 may still be a real issue under other conditions, but our specific reproduction case was Defender

    Why the Misdiagnosis

    1. TerminateProcess() was called — but by Windows Defender, not Node.js internals
    2. No exit handlers fired — because Defender's TerminateProcess() is a forced kill, identical behavior to what we attributed to Node
    3. Python survived — not because of language differences, but because Defender's heuristic didn't flag Python processes the same way
    4. Digital signature was valid — Defender uses behavioral heuristics, not just signature checks

    Evidence Log

    • Windows Security popup: Trojan:Win32/SuspExec.SE detected during debugging session
    • Claude Code and Manus AI browser automation worked fine on same machine (different process signatures)
    • After Defender exclusions: all tests pass, including bare setInterval detached spawn on v24.13.1

    Apologies for the initial misattribution. The #62125 issue may still be valid for other reporters — we can only correct our own reproduction case.


    Root cause analysis documented at: [Gist link TBD]

  7. github-actions commented on Jul 20, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 90 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  8. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jul 20, 2026
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

    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions