From f1fb05df80cb56780386416e6b1842ea0127e9ac Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sat, 25 Jul 2026 04:24:25 +0000 Subject: [PATCH] fix(eval): statically link rejection interceptor --- packages/coding-agent/src/cli.ts | 10 +++++----- .../src/eval/__tests__/js-context-manager.test.ts | 10 +++++++++- .../eval/__tests__/process-entry-import.test.ts | 15 ++++++++++++++- .../test/fixtures/js-process-entry-import.ts | 2 +- packages/utils/src/postmortem.ts | 12 +++--------- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index fd23dab0f..4bd970514 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -24,9 +24,11 @@ import { setProfile, VERSION, } from "@oh-my-pi/pi-utils/dirs"; +import { interceptUnhandledRejections } from "@oh-my-pi/pi-utils/postmortem"; import { declareWorkerHostEntry, installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias"; import { extractProfileFlags } from "./cli/profile-bootstrap"; +import { startJsEvalProcess } from "./eval/js/process-entry"; import type { WorkerInbound as JsWorkerInbound, WorkerOutbound as JsWorkerOutbound } from "./eval/js/worker-protocol"; import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol"; @@ -168,15 +170,13 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise { return true; } if (arg === JS_EVAL_PROCESS_ARG) { - // Profile bootstrap is complete: loading the host's global fatal-rejection - // guard here keeps dotenv out of the process-entry module's import graph. - const { postmortem } = await import("@oh-my-pi/pi-utils"); - const { startJsEvalProcess } = await import("./eval/js/process-entry"); + // The bootstrap-safe interceptor seam is linked statically so this selector + // cannot load profile-scoped environment state after dispatch has begun. // The JS evaluator forwards user-controlled payloads (tool-call args, // display outputs); a non-serializable one must fail that cell, not // SIGKILL the kernel and erase the eval session's state. await runIpcSubprocessWorker( - transport => startJsEvalProcess(transport, postmortem.interceptUnhandledRejections), + transport => startJsEvalProcess(transport, interceptUnhandledRejections), { rethrowConnectedSendErrors: true }, ); return true; diff --git a/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts b/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts index cd50d8cb6..c931093e1 100644 --- a/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts +++ b/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts @@ -426,10 +426,18 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation expect(result.output.trim()).toBe("42"); }); - it("keeps the isolated process alive after a stackless floated rejection", async () => { + it("keeps the isolated process alive after handled and stackless floated rejections", async () => { using tempDir = TempDir.createSync("@omp-js-process-rejection-"); const session = makeSession(tempDir.path()); const evalSessionId = `js-rejection:${crypto.randomUUID()}`; + const handled = await executeJs('await Promise.reject("handled rejection").catch(() => undefined); return 42;', { + cwd: tempDir.path(), + sessionId: evalSessionId, + session, + }); + expect(handled.exitCode).toBe(0); + expect(handled.output.trim()).toBe("42"); + const rejected = await executeJs( 'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);', { cwd: tempDir.path(), sessionId: evalSessionId, session }, diff --git a/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts b/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts index fb84aafe0..a9438562d 100644 --- a/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts +++ b/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "bun:test"; import * as path from "node:path"; import { TempDir } from "@oh-my-pi/pi-utils"; -it("imports the JS process entry without loading dotenv before profile bootstrap", async () => { +it("imports the CLI entry graph without loading dotenv before profile bootstrap", async () => { using tempDir = TempDir.createSync("@omp-js-process-import-"); await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n"); const env = Object.fromEntries( @@ -25,3 +25,16 @@ it("imports the JS process entry without loading dotenv before profile bootstrap expect(stdout).toBe(""); expect(stderr).toBe(""); }); + +it("statically links the JS process selector through the bootstrap-safe rejection seam", async () => { + const cliPath = path.resolve(import.meta.dir, "../../cli.ts"); + const source = await Bun.file(cliPath).text(); + const imports = new Bun.Transpiler({ loader: "tsx" }).scanImports(source.replace(/^#![^\n]*\n/, "")); + + expect(imports).toContainEqual({ + kind: "import-statement", + path: "@oh-my-pi/pi-utils/postmortem", + }); + expect(imports).toContainEqual({ kind: "import-statement", path: "./eval/js/process-entry" }); + expect(imports).not.toContainEqual({ kind: "dynamic-import", path: "@oh-my-pi/pi-utils" }); +}); diff --git a/packages/coding-agent/test/fixtures/js-process-entry-import.ts b/packages/coding-agent/test/fixtures/js-process-entry-import.ts index ff73f4730..f2f146754 100644 --- a/packages/coding-agent/test/fixtures/js-process-entry-import.ts +++ b/packages/coding-agent/test/fixtures/js-process-entry-import.ts @@ -1,3 +1,3 @@ -import "../../src/eval/js/process-entry"; +import "../../src/cli"; process.stdout.write(process.env.OMP_PROCESS_ENTRY_ENV_PROBE ?? ""); diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 9377e9cbd..b45a43bd7 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -9,7 +9,7 @@ import * as fs from "node:fs"; import inspector from "node:inspector"; import { isMainThread } from "node:worker_threads"; -import { logger } from "."; +import * as logger from "./logger"; import { restoreTerminalStderr } from "./stderr-guard"; // Cleanup reasons, in order of priority/meaning. @@ -149,18 +149,12 @@ export function isExpectedCleanupError(reason: unknown): boolean { return false; } -/** - * Interceptors consulted by the global `unhandledRejection` handler before the - * fatal path. See {@link interceptUnhandledRejections}. - */ +/** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */ const rejectionInterceptors = new Set<(reason: unknown) => boolean>(); /** * Register an interceptor consulted before an unhandled rejection tears the - * process down. Return `true` to consume the rejection — the interceptor owns - * reporting and the process continues. Used by embedded script runtimes (JS - * eval cells) whose user code can float rejections the host must not die for. - * Returns an unregister function. + * process down. A consuming interceptor owns reporting and keeps the process alive. */ export function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void { rejectionInterceptors.add(interceptor);