gemini-cli - ✅(Solved) Fix Windows SEA: child_process.fork() in SEA build spawns a second gemini session instead of running helper scripts [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
google-gemini/gemini-cli#26365Fetched 2026-05-03 04:52:35
View on GitHub
Comments
0
Participants
1
Timeline
2
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×1labeled ×1

Error Message

console.error('Usage: node sea-fork-repro.cjs <path-to-binary>');

Root Cause

child_process.fork(modulePath, args, opts) defaults opts.execPath to process.execPath. In a SEA build that is the gemini binary itself, so the spawned child runs sea-launch.cjs (and the bundled gemini.mjs) instead of executing the requested .js script.

The same dynamic affects any other downstream helper that uses child_process.fork() from inside the SEA, not just @lydell/node-pty.

Fix Action

Fixed

PR fix notes

PR #26366: fix(sea): run forked helper scripts directly instead of spawning a new session

Description (problem / solution / changelog)

Summary

In the SEA (Single Executable Application) build, child_process.fork(modulePath, args) uses process.execPath as the Node.js interpreter — which is gemini.exe itself. Any fork() call from app code or a transitive dependency therefore launches a second gemini session in a child process instead of executing the requested helper script.

The most visible victim is @lydell/node-pty's WindowsPtyAgent._getConsoleProcessList(), which fork()s conpty_console_list_agent.js during ConPTY teardown. On Windows, this means every run_shell_command invocation in the SEA build can spawn a second gemini session, with debug-log interleaving, lost CLI flags (e.g. --yolo becomes autoEdit), and a node-pty 5-second timeout instead of prompt console-handle reaping.

This PR detects fork-style invocation at SEA entry and runs the requested script directly.

Details

In sea/sea-launch.cjs, before any other startup work in main():

  1. Detect typeof process.send === 'function' — the runtime indicator that this process was spawned with an IPC channel (i.e. via fork()). Note: NODE_CHANNEL_FD is no longer set on the child env in modern Node.js, so process.send is the reliable signal.
  2. Scan process.argv[1..] for the first .js script that is not the binary itself. The SEA inserts a duplicate of execPath at argv[1] (the script-path slot a non-SEA Node.js would have), so for fork(helper.js, [pid]) the SEA child sees argv = [binary, binary, helper.js, pid].
  3. Normalize process.argv to [binary, script, ...remainingArgs] so the helper sees argv in the same positions a regular Node fork would deliver — important for helpers that read process.argv[2] (which conpty_console_list_agent.js does for the shell PID).
  4. Load the script via Module.createRequire(scriptPath). The SEA's built-in require() only resolves built-in modules (Node SEA docs), so createRequire rooted at the script path is required for the helper to load its native-addon dependencies.
  5. On failure, silently return rather than falling through to gemini startup — the parent's IPC timeout will recover, and we never want to spawn a second session.

The fix is generic: it benefits any fork()-using helper, not just node-pty.

Related Issues

Closes #26365

How to Validate

A standalone integration test is included as sea/sea-launch.fork.integration.test.cjs. It mirrors what node-pty does — fork()s a tiny helper script using the SEA binary as execPath and waits for the helper's IPC reply.

# 1. Build the SEA binary
npm run bundle
node scripts/build_binary.js

# 2. Run the integration test against it
node sea/sea-launch.fork.integration.test.cjs dist/win32-x64/gemini.exe

Expected after this PR (PASS):

elapsed: ~30 ms
message: {"status":"ok","receivedShellPid":129984,"runtimePid":...}
RESULT: PASS — fork()'d helper script ran and sent IPC message.

Without this PR (FAIL — bug present):

elapsed: 15007 ms (timeout)
message: null
child-stderr: ...Ripgrep is not available...   ← second gemini session
RESULT: FAIL — fork()'d helper did NOT respond via IPC.

To verify the BEFORE behavior locally, build a binary from main, run the integration test against it, and confirm it fails the same way.

End-to-end on Windows:

  1. Build the binary (above).
  2. Launch gemini.exe --yolo.
  3. Run any shell command (e.g. run shell command: pwd).
  4. Confirm: only one gemini.exe PID exists; GEMINI_DEBUG_LOG_FILE shows a single session; approvalMode stays yolo.

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed) — not required (internal SEA bootstrap fix)
  • Added/updated tests (if needed) — sea/sea-launch.fork.integration.test.cjs (integration) and updated sea/sea-launch.test.js
  • Noted breaking changes (if any) — none; the new branch only fires when process.send is set, which is never true for normal user invocations
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run (not affected — bug is SEA-only)
      • npx (not affected — bug is SEA-only)
      • SEA standalone binary (the affected path; verified PASS via integration test, BEFORE/AFTER)
      • Docker
    • Linux
      • npm run (SEA build also affected on Linux in principle; not yet verified)

Changed files

  • integration-tests/ui-hang-repro.test.ts (added, +58/-0)
  • packages/cli/src/ui/contexts/KeypressContext.tsx (modified, +106/-3)
  • sea/sea-launch.cjs (modified, +45/-0)
  • sea/sea-launch.fork.integration.test.cjs (added, +150/-0)
  • sea/sea-launch.test.js (modified, +53/-5)

Code Example

// node_modules/@lydell/node-pty/windowsPtyAgent.js
var agent = child_process.fork(
  path.join(__dirname, 'conpty_console_list_agent'),
  [_this._innerPid.toString()],
);

---

// sea-fork-repro.cjs
// Usage:  node sea-fork-repro.cjs <path-to-gemini.exe>
const { fork } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');

const TIMEOUT_MS = 15_000;
const binaryPath = process.argv[2];
if (!binaryPath || !fs.existsSync(binaryPath)) {
  console.error('Usage: node sea-fork-repro.cjs <path-to-binary>');
  process.exit(2);
}

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sea-fork-test-'));
const helperPath = path.join(tmpDir, 'fake_console_list_agent.js');
fs.writeFileSync(
  helperPath,
  `const shellPid = parseInt(process.argv[2], 10);
process.send({ status: 'ok', receivedShellPid: shellPid, runtimePid: process.pid });
process.exit(0);
`,
);

const startedAt = Date.now();
const child = fork(helperPath, ['129984'], {
  execPath: binaryPath,
  stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});

let messageReceived = null;
let exitInfo = null;
let stderrBuf = '';
child.stderr?.on('data', (d) => (stderrBuf += d.toString()));

let finished = false;
function finish() {
  if (finished) return;
  finished = true;
  const elapsedMs = Date.now() - startedAt;
  console.log('elapsed:', elapsedMs, 'ms');
  console.log('message:', JSON.stringify(messageReceived));
  console.log('exit:   ', JSON.stringify(exitInfo));
  if (stderrBuf) console.log('child-stderr:', stderrBuf.slice(0, 300));
  if (!child.killed && exitInfo === null) try { child.kill('SIGKILL'); } catch {}
  try { fs.unlinkSync(helperPath); fs.rmdirSync(tmpDir); } catch {}
  const ok = messageReceived && messageReceived.status === 'ok' && messageReceived.receivedShellPid === 129984;
  console.log(ok ? 'PASS' : 'FAIL — second gemini session spawned instead of helper');
  process.exit(ok ? 0 : 1);
}
child.on('message', (m) => { messageReceived = m; setImmediate(finish); });
child.on('exit', (c, s) => { exitInfo = { code: c, signal: s }; setTimeout(finish, 100); });
setTimeout(finish, TIMEOUT_MS);

---

elapsed: 15007 ms
message: null
exit:    null
child-stderr: Warning: True color (24-bit) support not detected. ...
              Ripgrep is not available. Falling back to GrepTool.
FAIL — second gemini session spawned instead of helper

---

elapsed: 29 ms
message: {"status":"ok","receivedShellPid":129984,"runtimePid":...}
exit:    null
PASS
RAW_BUFFERClick to expand / collapse

What happened?

In the standalone (SEA / Single Executable Application) Windows build of gemini, any call to child_process.fork(modulePath, args) made by application code or by a transitive dependency starts a brand-new gemini session in a child process instead of executing the requested helper script.

The most visible victim is @lydell/node-pty's WindowsPtyAgent._getConsoleProcessList() which is invoked from WindowsPtyAgent.kill() (and therefore from WindowsTerminal.destroy() / .kill()). It runs:

// node_modules/@lydell/node-pty/windowsPtyAgent.js
var agent = child_process.fork(
  path.join(__dirname, 'conpty_console_list_agent'),
  [_this._innerPid.toString()],
);

In the SEA build, process.execPath is gemini.exe itself, so fork() ends up running gemini.exe <conpty_console_list_agent> <pid> instead of node <conpty_console_list_agent> <pid>. The result:

  1. A second gemini session starts (full SEA bootstrap, runtime extraction, parent-runner + child-runner spawn).
  2. The forked-second-session inherits the original process's environment, so it picks up GEMINI_DEBUG_LOG_FILE and writes interleaved logs into the same file.
  3. It is launched with no CLI flags from the original invocation, so --yolo / approval-mode flags are lost — the second session ends up at approvalMode = autoEdit (or whatever the user's default is), even though the user had launched the first session with --yolo.
  4. node-pty's parent-side timeout (5 s) eventually fires, kills the spawned process, and resolves with [shellPid]. The expected { consoleProcessList: [...] } IPC message never arrives, so console-handle reaping for the dead PTY does not happen.
  5. On Windows Terminal this can also surface a duplicate UI flicker / restart-looking artifact when run_shell_command is used.

What did you expect to happen?

fork()-based helper modules — including @lydell/node-pty's conpty_console_list_agent.js — should execute their actual JavaScript helper, send their IPC reply, and exit. They should never trigger a second full gemini session.

Reproduction (no Gemini session needed — works against any SEA gemini.exe)

The integration test below mirrors what node-pty.windowsPtyAgent._getConsoleProcessList() does, and prints PASS/FAIL deterministically:

// sea-fork-repro.cjs
// Usage:  node sea-fork-repro.cjs <path-to-gemini.exe>
const { fork } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');

const TIMEOUT_MS = 15_000;
const binaryPath = process.argv[2];
if (!binaryPath || !fs.existsSync(binaryPath)) {
  console.error('Usage: node sea-fork-repro.cjs <path-to-binary>');
  process.exit(2);
}

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sea-fork-test-'));
const helperPath = path.join(tmpDir, 'fake_console_list_agent.js');
fs.writeFileSync(
  helperPath,
  `const shellPid = parseInt(process.argv[2], 10);
process.send({ status: 'ok', receivedShellPid: shellPid, runtimePid: process.pid });
process.exit(0);
`,
);

const startedAt = Date.now();
const child = fork(helperPath, ['129984'], {
  execPath: binaryPath,
  stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});

let messageReceived = null;
let exitInfo = null;
let stderrBuf = '';
child.stderr?.on('data', (d) => (stderrBuf += d.toString()));

let finished = false;
function finish() {
  if (finished) return;
  finished = true;
  const elapsedMs = Date.now() - startedAt;
  console.log('elapsed:', elapsedMs, 'ms');
  console.log('message:', JSON.stringify(messageReceived));
  console.log('exit:   ', JSON.stringify(exitInfo));
  if (stderrBuf) console.log('child-stderr:', stderrBuf.slice(0, 300));
  if (!child.killed && exitInfo === null) try { child.kill('SIGKILL'); } catch {}
  try { fs.unlinkSync(helperPath); fs.rmdirSync(tmpDir); } catch {}
  const ok = messageReceived && messageReceived.status === 'ok' && messageReceived.receivedShellPid === 129984;
  console.log(ok ? 'PASS' : 'FAIL — second gemini session spawned instead of helper');
  process.exit(ok ? 0 : 1);
}
child.on('message', (m) => { messageReceived = m; setImmediate(finish); });
child.on('exit', (c, s) => { exitInfo = { code: c, signal: s }; setTimeout(finish, 100); });
setTimeout(finish, TIMEOUT_MS);

Run: node sea-fork-repro.cjs path\to\gemini.exe

Observed result on a SEA Windows build:

elapsed: 15007 ms
message: null
exit:    null
child-stderr: Warning: True color (24-bit) support not detected. ...
              Ripgrep is not available. Falling back to GrepTool.
FAIL — second gemini session spawned instead of helper

The child-stderr lines come from the second gemini session that was started inside the forked child.

Expected result (after fix):

elapsed: 29 ms
message: {"status":"ok","receivedShellPid":129984,"runtimePid":...}
exit:    null
PASS

Root cause

child_process.fork(modulePath, args, opts) defaults opts.execPath to process.execPath. In a SEA build that is the gemini binary itself, so the spawned child runs sea-launch.cjs (and the bundled gemini.mjs) instead of executing the requested .js script.

The same dynamic affects any other downstream helper that uses child_process.fork() from inside the SEA, not just @lydell/node-pty.

Proposed fix

At the very top of sea/sea-launch.cjs's main():

  • detect that we are a forked child (typeof process.send === 'function'),
  • find the first .js script in process.argv that is not the binary itself,
  • normalize argv to the shape a regular Node.js fork would deliver ([binary, script, ...args]) so the helper's existing process.argv[2] reads continue to work,
  • load it via Module.createRequire(scriptPath) (the SEA's built-in require() only resolves built-in modules),
  • never fall through to gemini's startup path on failure — silently return so the parent's IPC timeout can recover.

A working implementation is in fix/sea-fork-execpath along with the integration test above (committed as sea/sea-launch.fork.integration.test.cjs). PR is incoming.

Client information

<details> <summary>Client Information</summary>
  • Platform: Windows 11 (10.0.26200)
  • Distribution: SEA standalone binary (gemini.exe)
  • Gemini CLI version: reproduced on 0.41 / 0.42 builds; the affected code path (sea/sea-launch.cjs and @lydell/node-pty 1.1.0) is unchanged on main as of f52b9b097.
  • Node.js version (used to build the SEA): v22.11.0
</details>

Login information

N/A — bug is independent of authentication; reproduces with no Gemini session at all (see standalone repro above).

Anything else we need to know?

The bug only manifests in the SEA distribution (gemini.exe). When gemini is invoked via npm install -g @google/gemini-cli so that process.execPath is the real node binary, fork() works correctly and the bug does not appear — which is likely why this has not been widely reported despite the affected code being on main.

extent analysis

TL;DR

To fix the issue, modify the sea-launch.cjs file to detect when it's being run as a forked child and execute the requested JavaScript script instead of launching a new gemini session.

Guidance

  • Identify if the process is a forked child by checking if process.send is a function.
  • Find the first .js script in process.argv that is not the binary itself and normalize argv to match the expected format for a regular Node.js fork.
  • Load the script using Module.createRequire(scriptPath) to ensure it can be executed correctly.
  • Implement a silent return if the script execution fails, allowing the parent's IPC timeout to recover.

Example

if (typeof process.send === 'function') {
  const scriptPath = process.argv.find(arg => arg.endsWith('.js') && arg !== process.execPath);
  if (scriptPath) {
    const require = Module.createRequire(scriptPath);
    // Normalize argv and execute the script
    process.argv = [process.execPath, scriptPath, ...process.argv.slice(2)];
    require(scriptPath);
    return; // Silent return on failure
  }
}

Notes

This fix is specific to the SEA distribution of gemini and does not affect installations where gemini is invoked via the node binary.

Recommendation

Apply the proposed fix to the sea-launch.cjs file to resolve the issue with child_process.fork() spawning a new gemini session instead of executing the requested helper script.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

gemini-cli - ✅(Solved) Fix Windows SEA: child_process.fork() in SEA build spawns a second gemini session instead of running helper scripts [1 pull requests, 1 participants]