diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d79d3d182..542c7448d5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -143,6 +143,19 @@ jobs:
- name: Run script tests
run: make script-test
+ shim-test-windows:
+ needs: fast-gate
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ - name: Run npm shim tests on Windows
+ run: node --test scripts/run.test.js
+
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -520,7 +533,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
- needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
+ needs: [fast-gate, unit-test, lint, script-test, shim-test-windows, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -533,6 +546,7 @@ jobs:
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| L2 | shim-test-windows | ${{ needs.shim-test-windows.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -559,6 +573,7 @@ jobs:
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
+ "${{ needs.shim-test-windows.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
diff --git a/Makefile b/Makefile
index a338519e3f..4b2f524532 100644
--- a/Makefile
+++ b/Makefile
@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
- $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
+ $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js scripts/run.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
diff --git a/scripts/ci-workflow.test.sh b/scripts/ci-workflow.test.sh
index 6acdb0adc1..ce07ab9a58 100644
--- a/scripts/ci-workflow.test.sh
+++ b/scripts/ci-workflow.test.sh
@@ -31,6 +31,7 @@ lint_section="$(awk '
/^ script-test:/ { exit }
' "$workflow")"
script_test_section="$(job_section script-test)"
+shim_test_windows_section="$(job_section shim-test-windows)"
deterministic_section="$(awk '
/^ deterministic-gate:/ { in_job = 1 }
in_job { print }
@@ -170,6 +171,34 @@ if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
exit 1
fi
+if ! grep -Fq "runs-on: windows-latest" <<<"$shim_test_windows_section"; then
+ echo "shim-test-windows must run on windows-latest so the npm shim is exercised on the platform it broke on"
+ exit 1
+fi
+if ! grep -Fq "node --test scripts/run.test.js" <<<"$shim_test_windows_section"; then
+ echo "shim-test-windows must run the npm shim tests"
+ exit 1
+fi
+if ! grep -Fq "node-version: '22'" <<<"$shim_test_windows_section"; then
+ echo "shim-test-windows must pin node-version so process.execPath fixtures stay reproducible"
+ exit 1
+fi
+if ! grep -Fq '"${{ needs.shim-test-windows.result }}"' <<<"$results_section"; then
+ echo "shim-test-windows must sit inside the results FAILED loop; needs and the summary table alone leave it non-blocking"
+ exit 1
+fi
+
+results_needs_line="$(grep -m1 '^ needs:' <<<"$results_section")"
+if ! grep -Fq "shim-test-windows" <<<"$results_needs_line"; then
+ echo "shim-test-windows must be listed in the results job's needs: array; GitHub returns an empty string for an undeclared dependency's result, so dropping it here would silently make it non-blocking even though the FAILED loop still references it"
+ exit 1
+fi
+
+if grep -Fq '${{ secrets.' <<<"$shim_test_windows_section"; then
+ echo "shim-test-windows must not reference secrets"
+ exit 1
+fi
+
if grep -Fq "metadata-gate:" "$workflow"; then
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
exit 1
@@ -184,6 +213,7 @@ for full_job in \
"$unit_test_section" \
"$lint_section" \
"$script_test_section" \
+ "$shim_test_windows_section" \
"$deterministic_section" \
"$coverage_job_section" \
"$dry_run_section" \
diff --git a/scripts/run.js b/scripts/run.js
index a560fc02c4..789acec464 100755
--- a/scripts/run.js
+++ b/scripts/run.js
@@ -67,6 +67,69 @@ if (args[0] === "install") {
try {
execFileSync(bin, args, { stdio: "inherit" });
} catch (e) {
- process.exit(e.status || 1);
+ // Every branch below that prints a diagnostic ends with
+ // `process.exitCode = 1` and a plain return/fallthrough, never
+ // `process.exit(1)`. On POSIX, writes to a piped stderr are asynchronous
+ // (unlike a TTY); `process.exit()` tears the process down immediately and
+ // can drop a write that has not finished flushing yet. That is exactly
+ // what happens when the child has just filled a piped stderr (the
+ // AI-agent/log-wrapper case this CLI is built for): `process.exit(1)`
+ // right after `console.error(...)` can lose the entire diagnostic.
+ // Leaving `process.exitCode` set and returning naturally lets the event
+ // loop drain pending writes before the process actually exits, so the
+ // diagnostic survives; the exit code is still 1 either way. The silent
+ // branches (numeric-status forward, quiet SIGINT/SIGTERM) print nothing,
+ // so they keep using `process.exit` directly.
+ // The binary ran and chose its own status — forward it untouched.
+ if (typeof e.status === "number") {
+ // Windows has no signals: a crashing native binary (access violation
+ // 0xC0000005, missing DLL 0xC0000135, killed by endpoint protection) shows
+ // up as an NTSTATUS number here, not via e.signal. Surface the error-severity
+ // range (0xC0000000+) as a crash instead of forwarding it silently. Exclude
+ // 0xC000013A (STATUS_CONTROL_C_EXIT), the Windows Ctrl+C code, to stay
+ // symmetric with the quiet SIGINT/SIGTERM allowlist. A Go binary never exits
+ // with a code in this range deliberately.
+ if (
+ process.platform === "win32" &&
+ e.status >= 0xc0000000 &&
+ e.status !== 0xc000013a
+ ) {
+ console.error(
+ `\nlark-cli: the native binary crashed (status 0x${(e.status >>> 0).toString(16)}).\n` +
+ ` path: ${bin}`
+ );
+ process.exitCode = 1;
+ return;
+ }
+ process.exit(e.status);
+ }
+ // SIGINT and SIGTERM are the explicit quiet allowlist for intentional
+ // interruption (Ctrl+C during `auth login`, for one). Other signals are
+ // crash evidence worth surfacing, but do not prove the binary failed to
+ // launch. Only print e.signal and the known bin path: e.message and related
+ // error fields can contain the caller's full argv.
+ if (e.signal) {
+ if (e.signal === "SIGINT" || e.signal === "SIGTERM") {
+ process.exit(1);
+ }
+ console.error(
+ `\nlark-cli: the native binary was terminated by signal ${e.signal}.\n` +
+ ` path: ${bin}`
+ );
+ process.exitCode = 1;
+ return;
+ }
+ // Neither: the launch itself failed. Report only what is actually known —
+ // permissions, file format, CPU architecture and endpoint policy all land
+ // here, and the errno is the only evidence. Print e.code and never
+ // e.message: when the child does run, e.message carries the full argv,
+ // which can contain values the caller passed on the command line.
+ const reason = typeof e.code === "string" ? e.code : "UNKNOWN";
+ console.error(
+ `\nlark-cli: failed to launch the native binary.\n` +
+ ` path: ${bin}\n` +
+ ` error: ${reason}`
+ );
+ process.exitCode = 1;
}
}
diff --git a/scripts/run.test.js b/scripts/run.test.js
new file mode 100644
index 0000000000..60cba40e8a
--- /dev/null
+++ b/scripts/run.test.js
@@ -0,0 +1,491 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { spawnSync, spawn, execSync } = require("child_process");
+
+const MARKER = "failed to launch the native binary";
+const IS_WINDOWS = process.platform === "win32";
+const IS_ROOT =
+ !IS_WINDOWS && typeof process.getuid === "function" && process.getuid() === 0;
+const BIN_NAME = "lark-cli" + (IS_WINDOWS ? ".exe" : "");
+const SENTINEL = "--sentinel=LEAKCANARY";
+const SENTINEL_TOKEN = "LEAKCANARY";
+// Single-line, easily-flippable platform gate for the win32 NTSTATUS crash
+// test below (spec 6.2 row 1b). To confirm its assertions are real (not a
+// vacuous skip), temporarily hardcode `true` here on a non-Windows host and
+// run the suite: the test then executes for real, and it MUST fail, because
+// run.js still checks the real process.platform (so its crash branch stays
+// dark) and this OS truncates process.exit() codes to 8 bits before Node
+// ever sees them (0xC0000005 -> 5). Restore this line to
+// `IS_WINDOWS` before committing.
+const IS_WIN32_CRASH_TESTABLE = IS_WINDOWS;
+
+// run.js resolves the native binary relative to its own directory
+// (
/../bin/lark-cli), so a fixture needs a full scripts/ + bin/ layout.
+// Building that under fs.mkdtempSync keeps the developer's real ./bin/lark-cli
+// untouched and keeps the location unpredictable. Never replace this with a
+// fixed path such as %TEMP%\lark-cli-test — dropping a copy of the node
+// executable into a predictable user-writable directory changes Windows' DLL
+// search order.
+function makeSandbox(t) {
+ // fs.realpathSync canonicalises the temp root. assertLaunchFailure checks
+ // res.stderr.includes(sandbox.bin), but the path run.js prints comes from
+ // __dirname, and Node canonicalises a main module's path. On macOS the
+ // difference is /var -> /private/var, a prefix extension, so substring
+ // containment still holds and the tests pass unnoticed. On Windows it is
+ // NOT a prefix extension: libuv's realpath uses GetFinalPathNameByHandle
+ // with FILE_NAME_NORMALIZED, which expands 8.3 short names, and
+ // GitHub-hosted Windows runners set TEMP to
+ // C:\Users\RUNNER~1\AppData\Local\Temp while the profile directory is
+ // runneradmin. Short-name expansion is a substitution, not a prefix
+ // extension, so includes() returns false and every launch-failure case
+ // fails on the one platform the shim-test-windows job exists to cover. Do
+ // not "simplify" this back to the bare mkdtempSync result.
+ const root = fs.realpathSync(
+ fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-run-"))
+ );
+ t.after(() =>
+ fs.rmSync(root, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 100,
+ })
+ );
+ fs.mkdirSync(path.join(root, "scripts"));
+ fs.mkdirSync(path.join(root, "bin"));
+ fs.copyFileSync(
+ path.join(__dirname, "run.js"),
+ path.join(root, "scripts", "run.js")
+ );
+ // run.js runs install.js when the binary is missing. A stub that exits 0
+ // without producing one lets the "installer succeeded but produced nothing"
+ // path reach the launch attempt instead of stopping earlier.
+ fs.writeFileSync(
+ path.join(root, "scripts", "install.js"),
+ "process.exit(0);\n"
+ );
+ return {
+ root,
+ bin: path.join(root, "bin", BIN_NAME),
+ runJs: path.join(root, "scripts", "run.js"),
+ };
+}
+
+function runShim(sandbox, args) {
+ return spawnSync(process.execPath, [sandbox.runJs, ...args], {
+ encoding: "utf8",
+ });
+}
+
+// A fixture that actually launches: a copy of the running node executable.
+// A #!/bin/sh script cannot be started as lark-cli.exe on Windows, which would
+// make every "the binary ran" case inert there.
+function installRunnableBinary(sandbox) {
+ fs.copyFileSync(process.execPath, sandbox.bin);
+ if (!IS_WINDOWS) {
+ fs.chmodSync(sandbox.bin, 0o755);
+ }
+}
+
+// `--` stops node from claiming the sentinel as one of its own options; without
+// it node bails out with "bad option" and exits 9, which would silently
+// invalidate the fixture instead of exercising the intended exit status.
+function runRanBinary(sandbox, code) {
+ return runShim(sandbox, ["-e", code, "--", SENTINEL]);
+}
+
+function assertBinaryRan(res) {
+ assert.ok(
+ !res.stderr.includes(MARKER),
+ `launch-failure diagnostic on a binary that ran: ${res.stderr}`
+ );
+ assert.ok(
+ !res.stderr.includes(SENTINEL_TOKEN),
+ `caller arguments leaked into stderr: ${res.stderr}`
+ );
+}
+
+function assertLaunchFailure(res, sandbox) {
+ assert.equal(res.status, 1);
+ assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
+ assert.ok(
+ res.stderr.includes(MARKER),
+ `missing diagnostic, stderr was: ${res.stderr}`
+ );
+ assert.ok(
+ res.stderr.includes(sandbox.bin),
+ `diagnostic omits the binary path: ${res.stderr}`
+ );
+ const line = res.stderr.match(/^ {2}error: (.+)$/m);
+ assert.ok(line, `diagnostic omits an error line: ${res.stderr}`);
+ const reason = line[1].trim();
+ assert.notEqual(reason, "", "error line is empty");
+ // Keep failure output factual and local. A launch failure is not necessarily
+ // a lark-cli bug, and prompting users to paste local paths into an issue adds
+ // noise and may expose machine-specific information.
+ assert.ok(
+ !res.stderr.includes("github.com/larksuite/cli/issues"),
+ `diagnostic should not prompt issue creation: ${res.stderr}`
+ );
+ assert.ok(
+ !res.stderr.includes("Please include"),
+ `diagnostic should not ask users to share local details: ${res.stderr}`
+ );
+ // The sentinel check for launch-failure cases is not redundant with the
+ // check in assertBinaryRan: when the launch fails, e.message contains only
+ // the errno, not the full argv (measured: String(e) is
+ // "Error: spawnSync ENOENT" and e.path is just the bin path --
+ // neither carries argv). However, the error object also carries
+ // e.spawnargs, which holds the complete argument list even on launch
+ // failure. This assertion catches any future edit that prints
+ // e.spawnargs, String(e), or the whole error object.
+ assert.ok(
+ !res.stderr.includes(SENTINEL_TOKEN),
+ `caller arguments leaked into stderr: ${res.stderr}`
+ );
+ return reason;
+}
+
+describe("run.js launch-failure diagnostics", () => {
+ it("reports a binary that fails to launch instead of exiting silently", (t) => {
+ const sandbox = makeSandbox(t);
+
+ if (IS_WINDOWS) {
+ // On win32 a zero-byte file is not a valid PE image, so CreateProcess
+ // genuinely fails to start it: this really is a launch failure there.
+ fs.writeFileSync(sandbox.bin, "");
+ } else {
+ // A 0-byte + executable file is NOT a launch failure on Linux/glibc.
+ // libuv uses execvp, and glibc's execvp falls back to running the file
+ // through /bin/sh whenever execve returns ENOEXEC. /bin/sh then
+ // executes the empty file as an empty script and exits 0 --
+ // execFileSync never throws, so this branch would never fire (measured
+ // as uid 0 in the Linux sandbox container: no throw, child exited 0).
+ // This is NOT true of POSIX generally: measured on macOS (posix_spawn,
+ // no shell fallback), the identical 0-byte 0755 file makes
+ // execFileSync THROW ENOEXEC instead. Do NOT "simplify" this back to a
+ // 0-byte file. A directory at the bin path passes the shim's existence
+ // check and then fails at exec with EACCES on both platforms, and
+ // unlike chmod 0o000 that holds for uid 0 too, so this still proves the
+ // branch when CI runs as root.
+ fs.mkdirSync(sandbox.bin);
+ }
+
+ const res = runShim(sandbox, ["--version", SENTINEL]);
+ const reason = assertLaunchFailure(res, sandbox);
+
+ if (IS_WINDOWS) {
+ // The errno is deliberately not pinned to a literal here: what Windows
+ // reports for a 0-byte .exe is still unverified, and finding out is
+ // the reason this file also runs on windows-latest. Only require that
+ // something specific was captured.
+ assert.notEqual(reason, "UNKNOWN", "the OS error was not captured");
+
+ // Endpoint protection can quarantine a zero-byte .exe, which would
+ // silently turn this into the missing-binary case.
+ assert.ok(fs.existsSync(sandbox.bin), "fixture binary vanished before launch");
+ assert.equal(
+ fs.statSync(sandbox.bin).size,
+ 0,
+ "fixture binary is no longer zero-byte"
+ );
+ } else {
+ // Measured with one identical directory fixture on both macOS (uid 501)
+ // and Linux (uid 0): EACCES both times, so this can be pinned exactly.
+ assert.equal(reason, "EACCES");
+ }
+
+ if (process.env.GITHUB_STEP_SUMMARY) {
+ fs.appendFileSync(
+ process.env.GITHUB_STEP_SUMMARY,
+ `- \`run.js\` unlaunchable binary on ${process.platform}: \`${reason}\`\n`
+ );
+ }
+ });
+
+ it(
+ "reports a binary that cannot be executed",
+ {
+ skip: IS_WINDOWS
+ ? "POSIX execute bit"
+ : IS_ROOT
+ ? "mode 0000 does not deny exec for uid 0, so this fixture cannot " +
+ "produce EACCES when running as root; the directory fixture above " +
+ "keeps EACCES covered"
+ : false,
+ },
+ (t) => {
+ const sandbox = makeSandbox(t);
+ fs.writeFileSync(sandbox.bin, "#!/bin/sh\nexit 0\n");
+ fs.chmodSync(sandbox.bin, 0o000);
+
+ const res = runShim(sandbox, ["--version", SENTINEL]);
+
+ assert.equal(assertLaunchFailure(res, sandbox), "EACCES");
+ }
+ );
+
+ it("reports a missing binary when the installer produced nothing", (t) => {
+ const sandbox = makeSandbox(t);
+
+ const res = runShim(sandbox, ["--version", SENTINEL]);
+
+ assert.equal(assertLaunchFailure(res, sandbox), "ENOENT");
+ });
+});
+
+describe("run.js pass-through when the binary runs", () => {
+ it("forwards a non-zero exit status unchanged", (t) => {
+ const sandbox = makeSandbox(t);
+ installRunnableBinary(sandbox);
+
+ const res = runRanBinary(sandbox, "process.exit(7)");
+
+ assert.equal(res.status, 7);
+ assertBinaryRan(res);
+ });
+
+ for (const signal of ["SIGINT", "SIGTERM"]) {
+ it(
+ `exits 1 without a diagnostic when the binary receives ${signal}`,
+ { skip: IS_WINDOWS ? "POSIX signals" : false },
+ (t) => {
+ const sandbox = makeSandbox(t);
+ installRunnableBinary(sandbox);
+
+ const res = runRanBinary(
+ sandbox,
+ `process.kill(process.pid, '${signal}')`
+ );
+
+ assert.equal(res.status, 1);
+ assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
+ assert.equal(res.stderr, "", `stderr should stay empty: ${res.stderr}`);
+ assertBinaryRan(res);
+ }
+ );
+ }
+
+ it(
+ "reports the signal when the binary crashes",
+ { skip: IS_WINDOWS ? "POSIX signals" : false },
+ (t) => {
+ const sandbox = makeSandbox(t);
+ installRunnableBinary(sandbox);
+
+ const res = runRanBinary(sandbox, "process.kill(process.pid, 'SIGKILL')");
+
+ const expected =
+ `\nlark-cli: the native binary was terminated by signal SIGKILL.\n` +
+ ` path: ${sandbox.bin}\n`;
+ assert.equal(res.status, 1);
+ assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
+ assert.equal(res.stderr, expected);
+ assert.ok(
+ !res.stderr.includes(MARKER),
+ `crash signal was misreported as a launch failure: ${res.stderr}`
+ );
+ assert.ok(
+ !res.stderr.includes(SENTINEL_TOKEN),
+ `caller arguments leaked into stderr: ${res.stderr}`
+ );
+ }
+ );
+
+ it(
+ "reports a win32 NTSTATUS crash instead of forwarding the status silently",
+ {
+ skip: IS_WIN32_CRASH_TESTABLE
+ ? false
+ : "win32-only: POSIX truncates process.exit() codes to 8 bits " +
+ "(0xC0000005 -> 5), so the NTSTATUS crash range this branch " +
+ "detects cannot be reproduced outside win32 (spec 6.2 row 1b)",
+ },
+ (t) => {
+ const sandbox = makeSandbox(t);
+ installRunnableBinary(sandbox);
+
+ const res = runRanBinary(sandbox, "process.exit(0xC0000005)");
+
+ assert.equal(res.status, 1);
+ assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
+ assert.ok(
+ res.stderr.includes("crashed (status 0xc0000005"),
+ `missing win32 crash diagnostic: ${res.stderr}`
+ );
+ assert.ok(
+ res.stderr.includes(sandbox.bin),
+ `diagnostic omits the binary path: ${res.stderr}`
+ );
+ assert.ok(
+ !res.stderr.includes(MARKER),
+ `win32 crash was misreported as a launch failure: ${res.stderr}`
+ );
+ assert.ok(
+ !res.stderr.includes(SENTINEL_TOKEN),
+ `caller arguments leaked into stderr: ${res.stderr}`
+ );
+ }
+ );
+
+ it("passes stdout and stderr through and exits 0", (t) => {
+ const sandbox = makeSandbox(t);
+ installRunnableBinary(sandbox);
+
+ const res = runRanBinary(
+ sandbox,
+ "console.log('shim-passthrough'); console.error('shim-stderr')"
+ );
+
+ assert.equal(res.status, 0);
+ assert.match(res.stdout, /shim-passthrough/);
+ // A shim that swallowed child stderr would be a variant of the very bug
+ // this branch repairs (issue #2053: launch failures with no evidence).
+ // This does not collide with assertBinaryRan's checks below: it asserts
+ // MARKER and the argv sentinel are absent from stderr, and "shim-stderr"
+ // is neither.
+ assert.match(res.stderr, /shim-stderr/);
+ assertBinaryRan(res);
+ });
+});
+
+// Regression for the flush-safety fix: `process.exitCode = 1` + natural
+// return (not `process.exit(1)`) after the diagnostic console.error() calls.
+// Writes to a piped stderr are asynchronous on POSIX; `process.exit()` can
+// tear the process down before that write reaches the pipe. This is the
+// exact scenario an AI agent / log wrapper hits: it captures the shim's
+// stderr through a pipe, the child fills it, and the diagnostic must still
+// arrive.
+//
+// To make this deterministic, the fixture is killed from OUTSIDE (by this
+// test, via its discovered pid) while it is still blocked mid-write on a
+// completely unread pipe, instead of self-signalling. Measured: if the
+// fixture kills itself after its own blocking write loop returns, that
+// return is proof room just opened up, so the diagnostic that follows
+// almost always finds room too and the bug never reproduces -- on both
+// Linux and macOS, self-signalling passed 100% regardless of
+// process.exit(1) vs process.exitCode, i.e. it never actually exercised the
+// backpressure path. An external kill has no such tell: the fixture can be
+// stopped while genuinely full, so run.js's own diagnostic write is
+// guaranteed to need the async path this fix protects.
+//
+// Linux-only: this exact harness reproduces the drop 100% reliably on Linux
+// (matching the script-test CI job, ubuntu-latest, and the AI-agent/
+// container use case this fix targets). On macOS the underlying run.js fix
+// is not in question -- a separate check confirmed the fixture genuinely
+// blocks on a full, unread pipe through run.js's own execFileSync/"inherit"
+// chain, same as Linux -- but child_process's "close" event was observed to
+// fire as soon as the shim process exits, before this test's reader had
+// drained (or in some runs, before it had even attached), independent of
+// process.exit(1) vs process.exitCode. That is a difference in how Node's
+// child_process "close" event interacts with unread pipe backlog once the
+// writer has exited (epoll vs kqueue), not evidence about run.js's
+// correctness, so it is not treated as a platform for this regression test.
+describe("run.js flush safety under stderr backpressure", () => {
+ it(
+ "keeps the crash-signal diagnostic when stderr is a slow-draining pipe",
+ {
+ skip:
+ process.platform === "linux"
+ ? false
+ : "Linux-only: this harness's determinism relies on Linux's " +
+ "pipe/close semantics (see comment above); not evidence about " +
+ "run.js itself, which is exercised on this platform by the " +
+ "other POSIX signal tests in this file",
+ timeout: 15000,
+ },
+ (t) => {
+ const sandbox = makeSandbox(t);
+ installRunnableBinary(sandbox);
+
+ // Bigger than any realistic OS pipe buffer (measured ~256KB on the
+ // Linux CI container), so the fixture's blocking fs.writeSync loop
+ // genuinely fills the pipe and blocks in the kernel until a reader
+ // drains it -- real backpressure, not a simulation. It never gets to
+ // write all of this; it is killed mid-loop.
+ const fillBytes = 5000000;
+ const childCode =
+ "const fs = require('fs');" +
+ `const buf = Buffer.alloc(${fillBytes}, 0x78);` +
+ "let off = 0;" +
+ "while (off < buf.length) { off += fs.writeSync(2, buf, off, buf.length - off); }" +
+ "console.error('unreachable: should have been killed first');";
+
+ return new Promise((resolve, reject) => {
+ const shim = spawn(
+ process.execPath,
+ [sandbox.runJs, "-e", childCode, "--", SENTINEL],
+ { stdio: ["ignore", "ignore", "pipe"] }
+ );
+
+ // Attach the close listener up front. With the fixed shim, run.js's
+ // own process stays alive until its diagnostic write actually
+ // flushes, but that is exactly what this test must not assume --
+ // attaching this late could miss an early "close".
+ const closed = new Promise((res) => shim.on("close", res));
+ shim.on("error", reject);
+
+ setTimeout(() => {
+ // run.js's execFileSync gives the fixture no pid of its own to
+ // the caller (it is a grandchild); discover it as the shim's
+ // child process instead.
+ let fixturePid = null;
+ try {
+ const out = execSync(`pgrep -P ${shim.pid}`).toString().trim();
+ fixturePid = out ? Number(out.split("\n")[0]) : null;
+ } catch (err) {
+ reject(
+ new Error(`could not discover the fixture pid: ${err.message}`)
+ );
+ return;
+ }
+ if (!fixturePid) {
+ reject(new Error("fixture pid not found under the shim"));
+ return;
+ }
+ process.kill(fixturePid, "SIGKILL");
+
+ // Leave the pipe completely unread for a further stretch so that
+ // whatever run.js attempts to write next lands on a pipe that is
+ // still genuinely full, not one this test has started draining.
+ setTimeout(() => {
+ let stderr = "";
+ shim.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ closed.then((code) => {
+ try {
+ assert.equal(code, 1);
+ assert.ok(
+ stderr.includes(
+ "the native binary was terminated by signal SIGKILL"
+ ),
+ `diagnostic dropped under stderr backpressure ` +
+ `(captured ${stderr.length} bytes): ` +
+ JSON.stringify(stderr.slice(-300))
+ );
+ assert.ok(
+ stderr.includes(sandbox.bin),
+ `diagnostic omits the binary path: ${stderr.slice(-300)}`
+ );
+ assert.ok(
+ !stderr.includes(SENTINEL_TOKEN),
+ `caller arguments leaked into stderr under backpressure`
+ );
+ resolve();
+ } catch (err) {
+ reject(err);
+ }
+ }, reject);
+ }, 300);
+ }, 300);
+ });
+ }
+ );
+});