diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad310e465..11cc9f2dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,12 @@ issue when you are not sure. repository preload, and the repository browser wrapper wait automatically for other commands using the same checkout's build output. Runtime tests hold exclusive access because their fixtures rebuild the -packages. Every selected package is rebuilt. Persistent build-output reuse is +packages. Coordinated runtime invocations freshly build their selected package +closure once before tests; hooks verify that same live invocation, checkout, +exclusive lease and successful build before omitting duplicate compilation. +Direct or unattested hooks rebuild. Source edits made during a run take effect +on the next invocation. Ordinary explicit builds still rebuild every selected +package. Watch/hot runs remain unattested. Persistent build-output reuse is disabled until its effective configuration, installed toolchain, runtime and stylesheet inputs can be qualified; package versions and symlink target strings are not diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index f44f89cff..617524c2b 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -291,6 +291,66 @@ this now-completed same-source count comparison. Dependency copies and private builds count in proof time and sampled VM resources; setup/install and queues remain separate step measurements. No deadline, shard count or N changed here. +### One fresh build per test invocation + +The [2026-10-20 option-B ruling for #2279](https://github.com/schlessera/brain-kit/issues/2279#issuecomment-6293488248) +permits one fresh package build under the invocation's continuously held write +lease. `scripts/test.ts` prepares the selected runtime consumers' forward +package closure before starting Bun tests. Each private unit worker requests a +full build inside that same build/test lifetime, after namespace entry. There +are still at most two workers, with serial compilers inside each. A worker can +now start tests while its sibling is still preparing; account for this scheduling +placement separately from duplicate compilation avoided. + +The seventeen runtime hooks use a checked helper. Admission requires the live +ancestor coordinator's PID/start identity, invocation nonce, canonical checkout +and root inode, active exclusive lease owner/descriptor/inode, and the actual +builder's completed successful package/child receipt for the required closure. +The coordinator waits for exit and both output EOFs before publishing a bounded +record through its held descriptor. Closing the descriptor or losing the owner +revokes it; leftover files and the builder's best-effort observation fingerprints +have no authority. Failed or signaled preparation stops the invocation before +tests; missing diagnostics after successful preparation leave hooks rebuilding. + +Build and test commands each run beneath a private process-group anchor that +stays alive until command exit and both output EOFs have been joined. Cancellation +signals only that anchored group, with one-second forced cleanup for survivors +and children forked during signal handling. Losing the wrapper's IPC lifetime +channel also stops the group. A command leader exiting first cannot orphan its +inherited-output holders, and a nested wrapper does not signal the outer lease +or unrelated siblings. The anchor closes its own output descriptors after launch +so it cannot manufacture EOF or hold output open itself. Cancellation after a +successful builder exit still prevents attestation. This is cooperating +process-group ownership, not containment of processes deliberately escaping +their process group; it adds no product extension interface. + +Every ordinary nested wrapper creates a fresh invocation. Only the explicitly +checked isolated external-speech child borrows its live parent's build, outside +both its setup and outer-test deadlines. Direct/unattested hooks rebuild; +standalone coordinated runtime invocations freshly prepare. Shared read batches, +unsupported alternate working directories and watch/hot runs remain unattested. +Ordinary explicit `scripts/build.ts` calls still compile every selected package, +including freshness/configuration/compiler/output-damage fixture controls. + +A receipt describes its own build, not whatever `dist` holds later. Every +writer of the shared package output (`scripts/build.ts`, including nested +wrappers, hook fallbacks and the CI build operation, and `scripts/clean.ts`) +first replaces the workspace's random output generation token, before any +mutation. The receipt records its build's token, and admission requires it to +be current. A later successful, failed or cancelled writer beneath the same +lease therefore revokes the outer invocation's authority, and its remaining +hooks rebuild. The token only revokes; matching it never makes output fresh. +`--help` and `-h` reach Bun without preparation. +Client/browser bundling stays fresh and consumers keep their default exports. + +Source edits during a run take effect at the next invocation, as explicitly +accepted by the ruling. The lease coordinates cooperating first-party writers; +it does not make source immutable or protect against hostile same-UID processes. +No persistent stamp, boolean skip, generic producer interface, assertion or +deadline change follows. This is distinct from deferred #2319 activation below. +Measure complete preparation, hook admission, test execution and cleanup costs; +small hook durations alone are not a whole-invocation speedup. + ### Opt-in compilation within one hosted workspace The [same-source compilation comparison](https://github.com/schlessera/brain-kit/issues/2321#issuecomment-6233589351) diff --git a/docs/process/test-waits.md b/docs/process/test-waits.md index 7e1688f31..eb81a165a 100644 --- a/docs/process/test-waits.md +++ b/docs/process/test-waits.md @@ -108,10 +108,10 @@ restrict egress. Failed scene preparation closes its owned browser context, including its page listeners, before rethrowing the original failure. The rendering helper (`finishRender`, -`packages/ui-react/tests/connection-continuity-runtime.test.ts:362-387`) waits +`packages/ui-react/tests/connection-continuity-runtime.test.ts:363-388`) waits finite animation completion, fonts and two stable geometry frames. It does **not** claim arbitrary asynchronous work is finished. The negative-window helper -(`settle`, `packages/ui-react/tests/connection-continuity-runtime.test.ts:396-406`) +(`settle`, `packages/ui-react/tests/connection-continuity-runtime.test.ts:397-407`) retains the original 500 ms and two animation frames. It does not add a wait for all finite animations at these negative-observation sites. The finite-animation helper serves the four original rendering call sites, its readiness control, diff --git a/docs/process/testing.md b/docs/process/testing.md index 856eec003..e434b58cd 100644 --- a/docs/process/testing.md +++ b/docs/process/testing.md @@ -524,7 +524,15 @@ credentials. A fork run awaiting maintainer approval has not passed. Hosted unit partitions run up to two serial Bun test processes through `scripts/unit-workers.ts`. Each owns its source checkout, copied frozen -install, build output and workspace lease. The wrapper preserves the caller's +install, build output and workspace lease. Full preparation now runs after +namespace entry inside the test wrapper, under one continuous build/test lease; +the two workers keep independent invocation identities and serial compilers. +A prepared worker may start tests while its sibling is still building. Account +for that scheduling change separately from duplicate hook builds. Runtime hooks +verify the live invocation and successful closure receipt before reuse; missing +authority rebuilds, and a failed preparation stops before tests without retry. +See [the invocation rule](../decisions/ci-utility.md#one-fresh-build-per-test-invocation). +The wrapper preserves the caller's umask, drops setup capabilities and binds a private maintenance ledger without changing within-worker child limits or hiding the host's `/tmp` from Docker. Its checkout container lives outside host `/tmp`, which native workers replace diff --git a/packages/ui-react/tests/answer-delivery-runtime.test.ts b/packages/ui-react/tests/answer-delivery-runtime.test.ts index 85b7b63b6..c4c7b3170 100644 --- a/packages/ui-react/tests/answer-delivery-runtime.test.ts +++ b/packages/ui-react/tests/answer-delivery-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; /** * Answer delivery (#910) in real Chrome, on the public ChatPage, against the * real host (`createApp`, its WebSocket, SQLite catalog and dispatch) with a @@ -82,8 +83,7 @@ beforeAll(async () => { // Unit shards start without dist. Generate the real package styles (and // their exported token dependency), as the sibling styled runtime fixtures // do, instead of depending on another test having built them first. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [buildOut, buildErr, buildCode] = await hook.child("package-build", build); + const [buildOut, buildErr, buildCode] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (buildCode !== 0) throw new Error(`Answer delivery fixture build failed (${buildCode}): ${buildOut}\n${buildErr}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-answer-delivery-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/approval-recovery-runtime.test.ts b/packages/ui-react/tests/approval-recovery-runtime.test.ts index 92d0a411a..ca47897ac 100644 --- a/packages/ui-react/tests/approval-recovery-runtime.test.ts +++ b/packages/ui-react/tests/approval-recovery-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -35,8 +36,7 @@ const hook = runtimeHookPhases("approval-recovery-runtime"); beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Recovery fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-approval-recovery-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/artifact-recovery-runtime.test.ts b/packages/ui-react/tests/artifact-recovery-runtime.test.ts index 064b9fb93..9e9e8ec5e 100644 --- a/packages/ui-react/tests/artifact-recovery-runtime.test.ts +++ b/packages/ui-react/tests/artifact-recovery-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -47,9 +49,8 @@ async function child(args: string[]) { } beforeAll(async () => { - // A focused source mutation still bundles the client below from its fresh - // source; only already-built, unchanged workspace dependencies may be reused. - if (process.env.BRAIN_ARTIFACT_DEPENDENCIES_BUILT !== "1") await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + // The invocation owns fresh package output; client bundling stays fresh below. + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("artifact-recovery-runtime.test.ts")); fixture = await mkdtemp(resolve(tmpdir(), "odysseus-artifact-recovery-")); brain = resolve(fixture, "brain"); await createFixtureBrain(repo, brain); diff --git a/packages/ui-react/tests/association-signout-runtime.test.ts b/packages/ui-react/tests/association-signout-runtime.test.ts index ec2aa7ecc..6156666cd 100644 --- a/packages/ui-react/tests/association-signout-runtime.test.ts +++ b/packages/ui-react/tests/association-signout-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -79,11 +80,8 @@ async function stopHost(h: Host | undefined) { const hook = runtimeHookPhases("association-signout-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { if (!executablePath && !process.env.BRAIN_PINNED_CHROMIUM) return; - if (!process.env.BRAIN_AUTH_FIXTURE_BUILT) { - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Local work fixture package build failed (${code}): ${out}\n${err}`); - } scratch = await mkdtemp(resolve(tmpdir(), "odysseus-association-signout-")); brain = resolve(scratch, "brain"); await hook.phase("fixture-files", () => createFixtureBrain(repo, brain)); diff --git a/packages/ui-react/tests/auth-expiry-runtime.test.ts b/packages/ui-react/tests/auth-expiry-runtime.test.ts index dcde88258..8b0496731 100644 --- a/packages/ui-react/tests/auth-expiry-runtime.test.ts +++ b/packages/ui-react/tests/auth-expiry-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -111,11 +112,8 @@ const hook = runtimeHookPhases("auth-expiry-runtime"); beforeAll(async () => { if (!executablePath) return; - if (!process.env.BRAIN_AUTH_FIXTURE_BUILT) { - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Local work fixture package build failed (${code}): ${out}\n${err}`); - } scratch = await mkdtemp(resolve(tmpdir(), "odysseus-auth-expiry-")); brain = resolve(scratch, "brain"); await hook.phase("fixture-brain", () => createFixtureBrain(repo, brain)); diff --git a/packages/ui-react/tests/chat-focus-runtime.test.ts b/packages/ui-react/tests/chat-focus-runtime.test.ts index 44ec497ea..4c1ed301b 100644 --- a/packages/ui-react/tests/chat-focus-runtime.test.ts +++ b/packages/ui-react/tests/chat-focus-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, statSync } from "node:fs"; @@ -33,8 +34,7 @@ beforeAll(async () => { if (!executablePath) return; // Browser imports use the published default exports. Rebuild from source // on every run so a marker mutation cannot be hidden by stale dist output. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Focus fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-chat-focus-")); brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/connection-continuity-runtime.test.ts b/packages/ui-react/tests/connection-continuity-runtime.test.ts index 719dfbb4c..cd204cdfd 100644 --- a/packages/ui-react/tests/connection-continuity-runtime.test.ts +++ b/packages/ui-react/tests/connection-continuity-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -204,8 +206,7 @@ function listen() { beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("connection-continuity-runtime.test.ts")); if (code !== 0) throw new Error(`Connection-continuity fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-continuity-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/draft-runtime.test.ts b/packages/ui-react/tests/draft-runtime.test.ts index b46724394..a99e9a7b8 100644 --- a/packages/ui-react/tests/draft-runtime.test.ts +++ b/packages/ui-react/tests/draft-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -67,8 +68,7 @@ const HARBOUR = png([30, 90, 160]); const hook = runtimeHookPhases("draft-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Draft fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-drafts-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/external-speech-runtime.test.ts b/packages/ui-react/tests/external-speech-runtime.test.ts index a7a8a830b..9c17ea6f0 100644 --- a/packages/ui-react/tests/external-speech-runtime.test.ts +++ b/packages/ui-react/tests/external-speech-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild, externalSpeechDelegation } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -33,8 +34,7 @@ if (worker) { if (!executablePath) return; const repo = resolve(import.meta.dir, "../../.."); process.stderr.write("[external-speech] building packages\n"); - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await runtimeHookPhases("external-speech-runtime").child("package-build", build, relay); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("external-speech-runtime"), relay); if (code !== 0) throw new Error(`Speech fixture package build failed: ${out}\n${err}`); process.stderr.write("[external-speech] constructing local speech and application servers\n"); speechServer = Bun.serve<{ audio: number }>({ hostname: "127.0.0.1", port: 0, @@ -146,9 +146,9 @@ if (worker) describe.skipIf(!executablePath)("external speech through public app else test.skipIf(!executablePath)("external speech: actual browser audio drains and unmount hard-stops capture in an isolated process", async () => { // Parent test files may install DOM/transport globals. The real app/build/ // Chrome harness gets a fresh runtime, as the existing media proof does. - const child = Bun.spawn([process.execPath, "run", "test", import.meta.path], { + const child = Bun.spawn([process.execPath, "run", "test", "--internal-external-speech", import.meta.path], { cwd: resolve(import.meta.dir, "../../.."), - env: { ...process.env, BRAIN_EXTERNAL_SPEECH_WORKER: "1" }, + env: { ...externalSpeechDelegation(resolve(import.meta.dir, "../../..")), BRAIN_EXTERNAL_SPEECH_WORKER: "1" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); // Keep stage evidence even if the runner times out before the child exits. diff --git a/packages/ui-react/tests/fixtures/runtime-hook-phases.ts b/packages/ui-react/tests/fixtures/runtime-hook-phases.ts index b4e1290ed..cd72f77a0 100644 --- a/packages/ui-react/tests/fixtures/runtime-hook-phases.ts +++ b/packages/ui-react/tests/fixtures/runtime-hook-phases.ts @@ -57,5 +57,5 @@ export function runtimeHookPhases(file: string) { report(stage, "complete", details); return result; } - return { phase, child }; + return { phase, child, report }; } diff --git a/packages/ui-react/tests/fixtures/runtime-package-build.ts b/packages/ui-react/tests/fixtures/runtime-package-build.ts new file mode 100644 index 000000000..1ae9d86f4 --- /dev/null +++ b/packages/ui-react/tests/fixtures/runtime-package-build.ts @@ -0,0 +1,27 @@ +/** Checked same-invocation preparation, otherwise the ordinary fresh builder. */ +import { invocationBuild, externalSpeechDelegation } from "../../../../scripts/test-invocation"; +import { inheritWorkspaceLease } from "../../../../scripts/workspace-lease.mjs"; +import { runtimeHookPhases } from "./runtime-hook-phases"; +export { externalSpeechDelegation }; + +export async function runtimePackageBuild(root: string, directories: readonly string[], hook: ReturnType, + consume?: (stream: ReadableStream) => Promise): Promise<[string, string, number]> { + const started = performance.now(); + const receipt = invocationBuild(root, directories); + const admissionMs = performance.now() - started; + if (receipt) { + const detail = { disposition: "attested", invocation: receipt.nonce, parentBuildPid: receipt.build.pid, parentBuildId: receipt.build.id, admissionMs }; + hook.report("package-build", "start", detail); + hook.report("package-build", "complete", detail); + return ["", "", 0]; + } + hook.report("package-build", "admission", { disposition: "built", admissionMs }); + const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", directories.join(",")], + inheritWorkspaceLease({ cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe" })); + const result = await hook.child("package-build", build, consume); + const resource = build.resourceUsage(); + hook.report("package-build", "cost", { disposition: "built", childPid: build.pid, durationMs: performance.now() - started, + cpuUserUs: resource ? Number(resource.cpuTime.user) : undefined, cpuSystemUs: resource ? Number(resource.cpuTime.system) : undefined, maxRssBytes: resource ? Number(resource.maxRSS) : undefined }); + if (result[2] !== 0 || build.signalCode) throw new Error(`Runtime package build failed (${result[2]}, ${build.signalCode ?? "no signal"}): ${result[0]}\n${result[1]}`); + return result; +} diff --git a/packages/ui-react/tests/haiku-model-runtime.test.ts b/packages/ui-react/tests/haiku-model-runtime.test.ts index 7fb36b51e..30833dcbc 100644 --- a/packages/ui-react/tests/haiku-model-runtime.test.ts +++ b/packages/ui-react/tests/haiku-model-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { statSync, mkdtempSync, rmSync } from "node:fs"; @@ -15,8 +16,7 @@ const hook = runtimeHookPhases("haiku-model-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { // Browser consumers follow the published default exports, which resolve to // dist. Build those outputs; never force a Bun condition in the consumer. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react"], { cwd: join(import.meta.dir, "../../.."), stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(join(import.meta.dir, "../../.."), ["ui-react"], hook); if (code !== 0) throw new Error(`Haiku fixture build failed (${code}): ${out}\n${err}`); const built = await hook.phase("client-build", () => Bun.build({ entrypoints: [join(import.meta.dir, "fixtures/haiku-model-browser.tsx")], target: "browser", outdir: scratch, naming: "client.js" })); if (!built.success) throw new Error(JSON.stringify(built.logs)); diff --git a/packages/ui-react/tests/html-preview-runtime.test.ts b/packages/ui-react/tests/html-preview-runtime.test.ts index 4790eba78..4d5c3804e 100644 --- a/packages/ui-react/tests/html-preview-runtime.test.ts +++ b/packages/ui-react/tests/html-preview-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; // #1084: an HTML file's script runs in the file viewer and in a new tab, but in // an opaque origin that cannot reach the app. Proven in real Chrome, against @@ -42,7 +43,7 @@ let sessionCookie = ""; async function child(args: string[]): Promise { const process = Bun.spawn(args, { cwd: repo, stdout: "pipe", stderr: "pipe", stdin: "ignore" }); - const [out, err, code] = await hook.child(args[1] === "scripts/build.ts" ? "package-build" : "fixture-child", process); + const [out, err, code] = await hook.child("fixture-child", process); if (code !== 0) throw new Error(`HTML preview fixture preparation failed (${code}): ${out}\n${err}`); } @@ -146,7 +147,7 @@ const hook = runtimeHookPhases("html-preview-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { if (!executablePath) return; // The client bundle uses fresh source plus ordinary package resolution. - await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); fixture = await mkdtemp(resolve(tmpdir(), "odysseus-html-preview-")); attacker = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => { const url = new URL(request.url); diff --git a/packages/ui-react/tests/local-work-runtime.test.ts b/packages/ui-react/tests/local-work-runtime.test.ts index 6b698025e..9468820a6 100644 --- a/packages/ui-react/tests/local-work-runtime.test.ts +++ b/packages/ui-react/tests/local-work-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -114,8 +115,7 @@ const hook = runtimeHookPhases("local-work-runtime"); beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Local work fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-local-work-")); brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/parallel-sessions-runtime.test.ts b/packages/ui-react/tests/parallel-sessions-runtime.test.ts index 0401dc22f..2343dc333 100644 --- a/packages/ui-react/tests/parallel-sessions-runtime.test.ts +++ b/packages/ui-react/tests/parallel-sessions-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -221,8 +223,7 @@ async function restartHost() { beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("parallel-sessions-runtime.test.ts")); if (code !== 0) throw new Error(`Parallel-sessions fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-parallel-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/scratch-links-runtime.test.ts b/packages/ui-react/tests/scratch-links-runtime.test.ts index 2dcfe301c..78ff6bb9a 100644 --- a/packages/ui-react/tests/scratch-links-runtime.test.ts +++ b/packages/ui-react/tests/scratch-links-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; // #544: the actual scanner -> clicked FileLink -> store -> served app -> // PDF/image viewer chain. Regex/classifier controls alone missed this failure. import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; @@ -30,7 +31,7 @@ const bytes: Record = {}; async function child(args: string[], cwd = repo): Promise { const process = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe", stdin: "ignore" }); - const [out, err, code] = await hook.child(args[1] === "scripts/build.ts" ? "package-build" : "client-build", process); + const [out, err, code] = await hook.child("client-build", process); if (code !== 0) throw new Error(`Scratch fixture preparation failed (${code}): ${out}\n${err}`); } @@ -40,7 +41,7 @@ beforeAll(async () => { if (!executablePath) return; // The client bundle uses fresh source plus ordinary package resolution. // Rebuild dependencies so a runtime mutation cannot hide behind stale dist. - await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); fixture = await mkdtemp(resolve(tmpdir(), "odysseus-scratch-links-")); brain = resolve(fixture, "brain"); await hook.phase("fixture-brain", () => createFixtureBrain(repo, brain)); diff --git a/packages/ui-react/tests/share-draft-runtime.test.ts b/packages/ui-react/tests/share-draft-runtime.test.ts index 80a001bb6..e494cc8bd 100644 --- a/packages/ui-react/tests/share-draft-runtime.test.ts +++ b/packages/ui-react/tests/share-draft-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { statSync } from "node:fs"; @@ -26,8 +28,7 @@ const prompts: Array<{ sessionId: string; text: string; images: number }> = []; let sequence = 0; beforeAll(async () => { // Browser bundles resolve the SDK's default dist exports, not its Bun source condition. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-sdk"], { cwd: resolve(import.meta.dir, "../../.."), stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(resolve(import.meta.dir, "../../.."), ["ui-sdk"], runtimeHookPhases("share-draft-runtime.test.ts")); if (code !== 0) throw new Error(`Share fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(join(tmpdir(), "odysseus-share-draft-")); const brain = join(scratch, "brain"); diff --git a/packages/ui-react/tests/tracker-runtime.test.ts b/packages/ui-react/tests/tracker-runtime.test.ts index a1357bd0b..5c0494502 100644 --- a/packages/ui-react/tests/tracker-runtime.test.ts +++ b/packages/ui-react/tests/tracker-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -38,8 +40,7 @@ const LOG = Array.from({ length: 60 }, (_, i) => `Day ${i + 1}: the wax held, an beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("tracker-runtime.test.ts")); if (code !== 0) throw new Error(`Tracker fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-trackers-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/turn-journal-runtime.test.ts b/packages/ui-react/tests/turn-journal-runtime.test.ts index 477024265..d2bb642cb 100644 --- a/packages/ui-react/tests/turn-journal-runtime.test.ts +++ b/packages/ui-react/tests/turn-journal-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { createFixtureWorkerStartupView } from "../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; import { afterAll, beforeAll, expect, test } from "bun:test"; @@ -26,12 +27,12 @@ const observability = createRecordingObservability(); let root = "", origin = "", browser: Browser, app: Awaited>, server: ReturnType; async function child(args: string[]) { const process = Bun.spawn(args, { cwd: repo, stdout: "pipe", stderr: "pipe", stdin: "ignore" }); - const [out, err, code] = await hook.child(args[1] === "scripts/build.ts" ? "package-build" : "fixture-child", process); + const [out, err, code] = await hook.child("fixture-child", process); if (code !== 0) throw new Error(`Journal fixture build failed (${code})\n${out}${err}`); } const hook = runtimeHookPhases("turn-journal-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { - if (process.env.BRAIN_JOURNAL_DEPENDENCIES_BUILT !== "1") await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); root = await mkdtemp(resolve(tmpdir(), "odysseus-journal-browser-")); const brain = resolve(root, "brain"), assets = resolve(root, "assets"); await hook.phase("fixture-files", () => createFixtureBrain(repo, brain)); await mkdir(assets); diff --git a/scripts/build-execution.ts b/scripts/build-execution.ts index cea217ace..3c9e00e0e 100644 --- a/scripts/build-execution.ts +++ b/scripts/build-execution.ts @@ -5,8 +5,8 @@ import { buildReceipt } from "./build-receipt"; import { markBinsExecutable } from "./bin-modes"; import type { PublishablePackage } from "./publishable-packages"; -export async function executeBuild(root: string, packages: PublishablePackage[], jobs: 1 | 2, subset = true): Promise { - const receipt = buildReceipt(root, { jobs, subset, packages: packages.map(pkg => pkg.dir) }); +export async function executeBuild(root: string, packages: PublishablePackage[], jobs: 1 | 2, subset = true, generation?: string): Promise { + const receipt = buildReceipt(root, { jobs, subset, generation, packages: packages.map(pkg => pkg.dir) }); const bunx = Bun.which("bunx"); if (!bunx) { console.error( diff --git a/scripts/build-receipt.ts b/scripts/build-receipt.ts index 32f367379..4652bf4b5 100644 --- a/scripts/build-receipt.ts +++ b/scripts/build-receipt.ts @@ -8,7 +8,7 @@ type Compiler = "tsc" | "css"; type Child = { compiler: Compiler; pid: number; startedAt: number; elapsedMs?: number; code?: number }; type Package = { name: string; state: "pending" | "running" | "complete" | "failed"; startedAt?: number; elapsedMs?: number; children: Child[] }; -export function buildReceipt(root: string, options: { jobs: number; subset: boolean; packages: readonly string[] }) { +export function buildReceipt(root: string, options: { jobs: number; subset: boolean; generation?: string; packages: readonly string[] }) { let available = true; function unavailable() { if (!available) return; @@ -17,10 +17,12 @@ export function buildReceipt(root: string, options: { jobs: number; subset: bool try { console.error(JSON.stringify({ kind: "workspace-build-receipt-unavailable", pid: process.pid })); } catch {} } const directory = join(root, "tmp"); - const path = join(directory, `build-${process.pid}-${randomUUID()}-probe.json`); + const requested = process.env.BRAIN_TEST_BUILD_ID; + const id = requested && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(requested) ? requested : randomUUID(); + const path = join(directory, `build-${process.pid}-${id}-probe.json`); const started = performance.now(); const packages: Package[] = options.packages.map(name => ({ name, state: "pending", children: [] })); - const receipt = { schemaVersion: 1, kind: "workspace-build", pid: process.pid, startedAt: Date.now(), + const receipt = { schemaVersion: 1, kind: "workspace-build", id, pid: process.pid, generation: options.generation, startedAt: Date.now(), mode: options.subset ? "subset" : "full", jobs: options.jobs, state: "running" as "running" | "complete", elapsedMs: 0, success: false, code: null as number | null, packages }; const elapsed = () => performance.now() - started; diff --git a/scripts/build.ts b/scripts/build.ts index 13c966292..755980772 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,7 +1,7 @@ import { resolve } from "path"; import { executeBuild } from "./build-execution"; import { selectBuildPackages } from "./publishable-packages.ts"; -import { ensureWorkspaceLease } from "./workspace-lease.mjs"; +import { advanceOutputGeneration, ensureWorkspaceLease } from "./workspace-lease.mjs"; // Build order is not load-bearing: each package's tsconfig.build.json extends // the root tsconfig, whose customConditions ["bun"] resolve cross-package @@ -54,6 +54,9 @@ if (dryRun) { const coordinated = await ensureWorkspaceLease(root, "write"); if (coordinated !== undefined) process.exit(coordinated); +// Revoke any live invocation's build before the first output mutation, so a +// failed or cancelled build here cannot leave an outer receipt describing dist. +const generation = advanceOutputGeneration(root); // Intent requests this one operation; its independent qualification and actual // captured input/output checks supply authority. Unqualified contexts rebuild. if (process.env.BRAIN_CI_BUILD_OPERATION === "isolated-v1") { @@ -61,4 +64,4 @@ if (process.env.BRAIN_CI_BUILD_OPERATION === "isolated-v1") { const code = await attemptCiBuild(root, { directories, jobs: jobs as 1 | 2, force }); if (code !== undefined) process.exit(code); } -process.exitCode = await executeBuild(root, packages, jobs as 1 | 2, directories !== undefined); +process.exitCode = await executeBuild(root, packages, jobs as 1 | 2, directories !== undefined, generation); diff --git a/scripts/clean.ts b/scripts/clean.ts index 47e24aec7..3097d496c 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,10 +1,11 @@ import { rmSync } from "fs"; import { resolve } from "path"; import { listPublishablePackages } from "./publishable-packages.ts"; -import { ensureWorkspaceLease } from "./workspace-lease.mjs"; +import { advanceOutputGeneration, ensureWorkspaceLease } from "./workspace-lease.mjs"; const coordinated = await ensureWorkspaceLease(resolve(import.meta.dir, ".."), "write"); if (coordinated !== undefined) process.exit(coordinated); +advanceOutputGeneration(resolve(import.meta.dir, "..")); // The same manifest-derived list the build writes dist/ for. const packages = listPublishablePackages(resolve(import.meta.dir, "..")).map((pkg) => pkg.dir); diff --git a/scripts/runtime-test-builds.ts b/scripts/runtime-test-builds.ts new file mode 100644 index 000000000..311d831fa --- /dev/null +++ b/scripts/runtime-test-builds.ts @@ -0,0 +1,30 @@ +/** Fixed runtime consumers; this table never narrows Bun's test discovery. */ +import { existsSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +export const runtimeTestBuilds: Readonly> = Object.fromEntries([ + "answer-delivery", "approval-recovery", "artifact-recovery", "association-signout", "auth-expiry", "chat-focus", + "connection-continuity", "draft", "external-speech", "html-preview", "local-work", "parallel-sessions", "scratch-links", "tracker", "turn-journal", +].map(name => [`packages/ui-react/tests/${name}-runtime.test.ts`, ["ui-react", "ui-server", "core"]])); +const special: Readonly> = { + "packages/ui-react/tests/haiku-model-runtime.test.ts": ["ui-react"], + "packages/ui-react/tests/share-draft-runtime.test.ts": ["ui-sdk"], +}; +export const runtimeBuildInventory = { ...runtimeTestBuilds, ...special }; + +/** Paths are already parsed by the wrapper's value-aware Bun argument parser. */ +export function runtimeBuildSelection(root: string, cwd: string, paths: readonly string[]): string[] { + const selected = new Set(); + for (const [file, packages] of Object.entries(runtimeBuildInventory)) { + if (!existsSync(join(root, file))) continue; + const absolute = join(root, file); + if (paths.some(path => { + if (isAbsolute(path) || path.startsWith(".")) { + const target = resolve(cwd, path); + return absolute === target || absolute.startsWith(`${target}/`); + } + return absolute.includes(path); + })) for (const pkg of packages) selected.add(pkg); + } + return [...selected]; +} diff --git a/scripts/test-invocation-child.ts b/scripts/test-invocation-child.ts new file mode 100644 index 000000000..909cfb1bc --- /dev/null +++ b/scripts/test-invocation-child.ts @@ -0,0 +1,131 @@ +/** Private build/test process-group lifetime. No persistent or product API. */ +import { closeSync, readdirSync, readFileSync } from "node:fs"; +import { inheritWorkspaceLease, processIdentity } from "./workspace-lease.mjs"; + +type Usage = { cpuTime: { user: number; system: number }; maxRSS: number } | undefined; +type Message = { kind: "anchor"; start: string } | { kind: "started"; pid: number } | + { kind: "exit"; code: number; signal: NodeJS.Signals | null; usage: Usage } | { kind: "error"; error: string }; + +/** The anchor stays alive after the command exits, until both output EOFs are + * joined. Its IPC channel is also a parent-lifetime pipe: a dead wrapper cannot + * leave this detached group running. Only this live anchor signals its group. + */ +export function invocationChild(command: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdin?: "ignore" | "inherit" } = {}) { + let commandPid = 0, signalCode: NodeJS.Signals | null = null, usage: Usage; + let started = false, exited = false, released = false; + let resolveReady!: () => void, rejectReady!: (error: Error) => void, resolveExit!: (code: number) => void; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + const exit = new Promise(resolve => { resolveExit = resolve; }); + void ready.catch(() => {}); + const anchor = Bun.spawn([process.execPath, import.meta.path, "--anchor", ...command], inheritWorkspaceLease({ + ...options, stdin: options.stdin ?? "ignore", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32", + ipc(message: Message, child: ReturnType) { + if (message.kind === "error") console.error("Invocation command failed", message.error); + if (message.kind === "anchor") { + // The acknowledged anchor cannot exit normally or launch the command + // before this handshake. Bind its PID/start before granting launch. + if (process.platform === "linux" && processIdentity(child.pid)?.start !== message.start) { child.kill("SIGKILL"); return; } + child.send({ kind: "launch" }); + } else if (message.kind === "started") { + commandPid = message.pid; started = true; resolveReady(); + } else if (message.kind === "exit") { + signalCode = message.signal; usage = message.usage; exited = true; resolveExit(message.code); + } + }, + })); + void anchor.exited.then(code => { + if (!started) rejectReady(new Error("Invocation anchor stopped before command startup")); + if (!exited) { signalCode = anchor.signalCode; resolveExit(code || 1); } + }); + let settling: Promise | undefined; + const settled = () => settling ??= (async () => { + await anchor.exited; + if (process.platform !== "linux") return; + // killpg has delivered SIGKILL, but reaping the anchor alone does not prove + // its last member has stopped yet. Observe the group; never signal a numeric + // group ID after its anchor died (and might eventually be reused). + const liveMembers = () => readdirSync("/proc").some(name => { + if (!/^\d+$/.test(name)) return false; + try { + const stat = readFileSync(`/proc/${name}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + return !["Z", "X"].includes(fields[0]!) && Number(fields[2]) === anchor.pid && Number(fields[3]) === anchor.pid; + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return false; } + }); + const end = Date.now() + 1_000; + while (liveMembers() && Date.now() < end) await Bun.sleep(10); + if (liveMembers()) throw new Error("Invocation process group cleanup incomplete"); + })(); + let stopping: Promise | undefined; + return { + ready, + get pid() { return commandPid; }, + get signalCode() { return signalCode; }, + get cancelled() { return stopping !== undefined; }, + stdout: anchor.stdout as ReadableStream, stderr: anchor.stderr as ReadableStream, exited: exit, + resourceUsage: () => usage, + stop(signal: NodeJS.Signals): Promise { + if (!stopping) { + if (anchor.exitCode === null && anchor.signalCode === null) anchor.send({ kind: "stop", signal }); + stopping = settled(); + } + return stopping; + }, + async finish() { + if (!released) { + released = true; + if (anchor.exitCode === null && anchor.signalCode === null) anchor.send({ kind: "release" }); + } + await settled(); + }, + }; +} + +if (import.meta.main && process.argv[2] === "--anchor") { + if (!process.send) throw new Error("Invocation anchor requires its private lifetime channel"); + const pid = process.pid; + if (process.platform === "linux") { + const fields = readFileSync("/proc/self/stat", "utf8").split(") ").at(-1)!.split(" "); + if (Number(fields[2]) !== pid || Number(fields[3]) !== pid) throw new Error("Invocation anchor requires its own session and process group"); + } + let stopping = false, launched = false; + let command: ReturnType | undefined; + // The anchor, unlike its command, must survive TERM until group escalation. + const killGroup = (signal: NodeJS.Signals) => { + if (process.platform === "win32") { command?.kill(signal); process.exit(1); } + process.kill(-pid, signal); + }; + const stop = (signal: NodeJS.Signals) => { + if (stopping) return; + stopping = true; + killGroup(signal); + // Includes children forked by a signal handler after the first signal. + setTimeout(() => killGroup("SIGKILL"), 1_000); + }; + const send = (message: Message) => { + try { process.send!(message); } + catch { stop("SIGTERM"); } // The parent may die between exit and reporting. + }; + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => stop(signal)); + process.on("disconnect", () => stop("SIGTERM")); + process.on("message", async (message: { kind: string; signal?: NodeJS.Signals }) => { + if (message.kind === "stop") { stop(message.signal ?? "SIGTERM"); return; } + if (message.kind === "release") { killGroup("SIGKILL"); return; } + if (message.kind !== "launch" || launched || stopping) return; + launched = true; + try { + command = Bun.spawn(process.argv.slice(3), inheritWorkspaceLease({ stdin: "inherit", stdout: "inherit", stderr: "inherit" })); + send({ kind: "started", pid: command.pid }); + // Keep only the command's output descriptors alive. The parent can now + // observe actual descendant EOF while this group anchor remains live. + closeSync(1); closeSync(2); + const code = await command.exited; + const resource = command.resourceUsage(); + const usage: Usage = resource ? { cpuTime: { user: Number(resource.cpuTime.user), system: Number(resource.cpuTime.system) }, maxRSS: Number(resource.maxRSS) } : undefined; + send({ kind: "exit", code, signal: command.signalCode, usage }); + } catch (error) { send({ kind: "error", error: String(error) }); stop("SIGTERM"); } + }); + send({ kind: "anchor", start: processIdentity(pid)?.start ?? "" }); + // IPC can be unreferenced by the runtime when no message handler is active. + setInterval(() => {}, 60_000); +} diff --git a/scripts/test-invocation.ts b/scripts/test-invocation.ts new file mode 100644 index 000000000..48eecddb0 --- /dev/null +++ b/scripts/test-invocation.ts @@ -0,0 +1,188 @@ +/** One fresh build owned by one live test invocation; never cross-run freshness. */ +import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { selectBuildPackages } from "./publishable-packages"; +import { liveAncestor, outputGeneration, processIdentity, workspaceLeaseIdentity } from "./workspace-lease.mjs"; +import { invocationChild } from "./test-invocation-child"; + +const AUTHORITY = "BRAIN_TEST_BUILD_AUTHORITY"; +const NONCE = "BRAIN_TEST_INVOCATION_NONCE"; +const DELEGATION = "BRAIN_TEST_SPEECH_DELEGATION"; +const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; +const digest = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex"); +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const positive = (n: unknown): n is number => Number.isSafeInteger(n) && Number(n) > 0; +const identity = (root: string) => { const path = realpathSync(root), st = statSync(path); return { path, dev: st.dev, ino: st.ino }; }; +const event = (event: string, data: Record = {}) => console.error(JSON.stringify({ kind: "test-invocation", event, pid: process.pid, at: Date.now(), ...data })); + +type Authority = { version: 1; nonce: string; ownerPid: number; ownerStart: string; fd: number; dev: number; ino: number; sha256: string }; +type CompletedBuild = { schemaVersion: number; kind: string; pid: number; id: string; generation: string; state: string; success: boolean; code: number; jobs: number; + packages: { name: string; state: string; children: { compiler: string; pid: number; code: number; elapsedMs: number }[] }[] }; +type InvocationRecord = { version: 1; nonce: string; ownerPid: number; ownerStart: string; root: ReturnType; lease: NonNullable>; build: CompletedBuild }; + +function readRegular(path: string): { bytes: Buffer; dev: number; ino: number } { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = fstatSync(fd); + if (!before.isFile() || before.size < 2 || before.size > 1_048_576) throw new Error("Invalid invocation receipt file"); + const bytes = readFileSync(fd), after = fstatSync(fd); + if (bytes.length !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error("Changed invocation receipt"); + return { bytes, dev: before.dev, ino: before.ino }; + } finally { closeSync(fd); } +} + +function completedBuild(root: string, build: CompletedBuild, pid: number, id: string, expected?: readonly string[]): boolean { + if (!build || build.schemaVersion !== 1 || build.kind !== "workspace-build" || build.pid !== pid || build.id !== id || + !positive(pid) || !uuid.test(id) || typeof build.generation !== "string" || !uuid.test(build.generation) || + build.generation !== outputGeneration(root) || build.state !== "complete" || build.success !== true || build.code !== 0 || build.jobs !== 1 || + !Array.isArray(build.packages) || !build.packages.length || build.packages.length > 128) return false; + const names = build.packages.map(pkg => pkg?.name); + if (names.some(name => typeof name !== "string") || new Set(names).size !== names.length || + (expected && !same(names, expected)) || !same(names, selectBuildPackages(root, names).map(pkg => pkg.dir))) return false; + return build.packages.every(pkg => pkg.state === "complete" && Array.isArray(pkg.children) && + same(pkg.children.map(child => child.compiler), ["tsc", ...(["ui-react", "ui-kit"].includes(pkg.name) ? ["css"] : [])]) && + pkg.children.every(child => positive(child.pid) && child.code === 0 && Number.isFinite(child.elapsedMs) && child.elapsedMs >= 0)); +} + +/** Check the live owner's descriptor, never a pathname from the environment. */ +export function invocationBuild(root: string, directories: readonly string[]): InvocationRecord | undefined { + try { + if (process.platform !== "linux" || (process.env[AUTHORITY]?.length ?? 0) > 4096) return; + // Direct Bun watch/hot entrypoints can inherit a live outer locator too. + if (readFileSync("/proc/self/cmdline", "utf8").split("\0").some(arg => /^(?:--watch|--hot)(?:=|$)/.test(arg))) return; + const locator = JSON.parse(process.env[AUTHORITY] ?? "null") as Authority | null; + if (!locator || locator.version !== 1 || !uuid.test(locator.nonce) || locator.nonce !== process.env[NONCE] || + !positive(locator.ownerPid) || !positive(locator.fd) || locator.fd < 3 || !/^[0-9a-f]{64}$/.test(locator.sha256) || + !liveAncestor(locator.ownerPid, locator.ownerStart)) return; + const path = `/proc/${locator.ownerPid}/fd/${locator.fd}`; + const st = statSync(path); + if (!st.isFile() || st.dev !== locator.dev || st.ino !== locator.ino || st.size < 2 || st.size > 1_048_576) return; + // /proc's descriptor is deliberately followed; no caller-controlled path is opened. + const bytes = readFileSync(path); + if (bytes.length !== st.size || digest(bytes) !== locator.sha256) return; + const record = JSON.parse(bytes.toString()) as InvocationRecord; + const lease = workspaceLeaseIdentity(root); + if (record.version !== 1 || record.nonce !== locator.nonce || record.ownerPid !== locator.ownerPid || record.ownerStart !== locator.ownerStart || + !same(record.root, identity(root)) || !lease || lease.mode !== "write" || !same(record.lease, lease) || + !completedBuild(root, record.build, record.build?.pid, record.build?.id)) return; + const built = new Set(record.build.packages.map(pkg => pkg.name)); + if (!selectBuildPackages(root, directories).every(pkg => built.has(pkg.dir))) return; + const after = statSync(path); + // Any later writer of the shared output, even a failed nested one, has + // advanced the generation and so revoked this receipt. + if (after.dev !== st.dev || after.ino !== st.ino || after.size !== st.size || after.ctimeMs !== st.ctimeMs || + outputGeneration(root) !== record.build.generation || !liveAncestor(locator.ownerPid, locator.ownerStart)) return; + return record; + } catch { /* Missing, stale or malformed authority always rebuilds in the hook. */ } +} + +/** Only the isolated external-speech wrapper may deliberately borrow this run. */ +export function externalSpeechDelegation(root: string): NodeJS.ProcessEnv { + const record = invocationBuild(root, ["ui-react", "ui-server", "core"]); + return { ...process.env, [DELEGATION]: record ? JSON.stringify({ nonce: record.nonce, pid: process.pid, start: processIdentity(process.pid)?.start }) : "" }; +} + +export function acceptsSpeechDelegation(root: string, paths: readonly string[]): boolean { + try { + if (process.env.BRAIN_EXTERNAL_SPEECH_WORKER !== "1" || paths.length !== 1 || + resolve(root, paths[0]!) !== join(root, "packages/ui-react/tests/external-speech-runtime.test.ts")) return false; + const request = JSON.parse(process.env[DELEGATION] ?? "null"); + const record = invocationBuild(root, ["ui-react", "ui-server", "core"]); + return Boolean(record && request?.nonce === record.nonce && liveAncestor(request.pid, request.start)); + } catch { return false; } +} + +export function clearInvocation(): string { + delete process.env[AUTHORITY]; delete process.env[DELEGATION]; + return process.env[NONCE] = randomUUID(); +} + +/** Called after write admission. Caller forwards cancellation throughout build/test. */ +export async function prepareInvocation(root: string, directories: readonly string[] | undefined, + started: (child: ReturnType) => void): Promise<{ code: number; dispose: () => void }> { + const nonce = process.env[NONCE]!; + const selected = selectBuildPackages(root, directories).map(pkg => pkg.dir); + const buildId = randomUUID(), before = performance.now(); + const child = invocationChild([process.execPath, join(root, "scripts/build.ts"), ...(directories ? ["--for", directories.join(",")] : [])], + { cwd: root, stdin: "ignore", env: { ...process.env, BRAIN_TEST_BUILD_ID: buildId } }); + started(child); + await child.ready; + event("build-start", { nonce, builderPid: child.pid, buildId, packages: selected }); + async function drain(stream: ReadableStream, channel: "stdout" | "stderr") { + let bytes = 0; + for await (const chunk of stream) { bytes += chunk.length; process[channel].write(chunk); } + event(`${channel}-complete`, { nonce, builderPid: child.pid, bytes }); + } + const results = await Promise.allSettled([child.exited.then(code => { event("build-exit", { nonce, builderPid: child.pid, code, signal: child.signalCode }); return code; }), + drain(child.stdout as ReadableStream, "stdout"), drain(child.stderr as ReadableStream, "stderr")]); + await child.finish(); + const resource = child.resourceUsage(); + const usage = resource ? { cpuUserUs: Number(resource.cpuTime.user), cpuSystemUs: Number(resource.cpuTime.system), + maxRssBytes: Number(resource.maxRSS) } : undefined; + const exited = results[0]; + const code = exited.status === "fulfilled" ? exited.value : 1; + if (code !== 0 || child.signalCode || child.cancelled || results.some(result => result.status === "rejected")) { + event("preparation-failed", { nonce, builderPid: child.pid, code, usage, elapsedMs: performance.now() - before }); + return { code: code || 1, dispose() {} }; + } + let fd: number | undefined; + let directory: { path: string; dev: number; ino: number } | undefined; + let named: { path: string; dev: number; ino: number } | undefined; + const dispose = () => { + delete process.env[AUTHORITY]; + const errors: unknown[] = []; + if (fd !== undefined) { try { closeSync(fd); } catch (error) { errors.push(error); } fd = undefined; } + if (named) { + try { + const current = lstatSync(named.path, { throwIfNoEntry: false }); + if (current) { + if (!current.isFile() || current.dev !== named.dev || current.ino !== named.ino) throw new Error("Replaced invocation receipt; retained scratch"); + unlinkSync(named.path); + } + named = undefined; + } catch (error) { errors.push(error); } + } + if (directory) { + try { + const current = lstatSync(directory.path, { throwIfNoEntry: false }); + if (current) { + if (!current.isDirectory() || current.dev !== directory.dev || current.ino !== directory.ino) throw new Error("Replaced invocation directory; retained scratch"); + // Never recurse: an unrelated entry or failed owned unlink is retained. + rmdirSync(directory.path); + } + directory = undefined; + } catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, "Invocation receipt cleanup failed"); + }; + try { + const receipt = readRegular(join(root, "tmp", `build-${child.pid}-${buildId}-probe.json`)); + const build = JSON.parse(receipt.bytes.toString()) as CompletedBuild; + const lease = workspaceLeaseIdentity(root), ownerStart = processIdentity(process.pid)?.start; + if (!completedBuild(root, build, child.pid, buildId, selected) || !lease || lease.mode !== "write" || !ownerStart || !uuid.test(nonce)) throw new Error("Unattested completed preparation"); + const record: InvocationRecord = { version: 1, nonce, ownerPid: process.pid, ownerStart, root: identity(root), lease, build }; + const bytes = JSON.stringify(record); + const scratch = mkdtempSync(join(tmpdir(), "brain-test-invocation-")), dirStat = lstatSync(scratch); + directory = { path: scratch, dev: dirStat.dev, ino: dirStat.ino }; + const path = join(scratch, "receipt"); + const writer = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + const created = fstatSync(writer); + named = { path, dev: created.dev, ino: created.ino }; + try { writeFileSync(writer, bytes); } finally { closeSync(writer); } + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const st = fstatSync(fd); + if (!st.isFile() || st.dev !== named.dev || st.ino !== named.ino) throw new Error("Replaced invocation receipt before publication"); + unlinkSync(path); named = undefined; + rmdirSync(scratch); directory = undefined; + const locator: Authority = { version: 1, nonce, ownerPid: process.pid, ownerStart, fd, dev: st.dev, ino: st.ino, sha256: digest(bytes) }; + process.env[AUTHORITY] = JSON.stringify(locator); + event("build-attested", { nonce, builderPid: child.pid, buildId, packages: selected, usage, elapsedMs: performance.now() - before }); + return { code: 0, dispose }; + } catch { + dispose(); + event("build-unattested", { nonce, builderPid: child.pid, usage, elapsedMs: performance.now() - before }); + return { code: 0, dispose() {} }; + } +} diff --git a/scripts/test.ts b/scripts/test.ts index f11d243de..f0613158f 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -18,7 +18,11 @@ import { constants } from "node:os"; import { join, resolve } from "node:path"; -import { ensureWorkspaceLease, inheritWorkspaceLease, workspaceTestAccess } from "./workspace-lease.mjs"; +import { realpathSync } from "node:fs"; +import { invocationChild } from "./test-invocation-child"; +import { runtimeBuildSelection } from "./runtime-test-builds"; +import { acceptsSpeechDelegation, clearInvocation, prepareInvocation } from "./test-invocation"; +import { ensureWorkspaceLease, workspaceTestAccess } from "./workspace-lease.mjs"; /** What runs when the caller names no path. */ export const DEFAULT_ROOTS: readonly string[] = ["packages", "tests"]; @@ -95,12 +99,12 @@ function shortTakesNext(token: string): boolean { } /** The `bun test` argv for the arguments `bun run test` received. */ -export function testArgv(args: readonly string[]): string[] { - let hasPath = false; +export function testPaths(args: readonly string[]): string[] { + const paths: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]!; if (arg === "--") { - hasPath ||= i + 1 < args.length; + paths.push(...args.slice(i + 1)); break; } // A bare `-` is a positional to Bun, like any other path. @@ -112,9 +116,13 @@ export function testArgv(args: readonly string[]): string[] { } continue; } - hasPath = true; + paths.push(arg); } - return hasPath ? [...args] : [...DEFAULT_PATH_ARGS, ...args]; + return paths; +} + +export function testArgv(args: readonly string[]): string[] { + return testPaths(args).length ? [...args] : [...DEFAULT_PATH_ARGS, ...args]; } if (import.meta.main) { @@ -122,6 +130,7 @@ if (import.meta.main) { if (coordinated !== undefined) process.exit(coordinated); let args = process.argv.slice(2); let argv: string[]; + let fullPreparation = false, delegatedSpeech = false, help = false, unsupported = realpathSync(process.cwd()) !== realpathSync(resolve(import.meta.dir, "..")); try { const balanced: string[] = []; const forwarded: string[] = []; @@ -131,6 +140,11 @@ if (import.meta.main) { forwarded.push(...args.slice(i)); break; } + if (arg === "--internal-full-preparation") { fullPreparation = true; continue; } + if (arg === "--internal-external-speech") { delegatedSpeech = true; continue; } + // Bun prints usage and runs nothing, so help never builds or fails on a compiler error. + if (arg === "--help" || arg === "-h") help = true; + if (["--watch", "--hot", "--cwd"].some(flag => arg === flag || arg.startsWith(`${flag}=`))) unsupported = true; if (arg === "--balanced-shard") throw new Error("Expected --balanced-shard=M/N"); if (arg.startsWith("--balanced-shard=")) { balanced.push(arg); @@ -157,15 +171,47 @@ if (import.meta.main) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } - const proc = Bun.spawn([process.execPath, "test", "--preload", join(import.meta.dir, "test-network-preload.ts"), ...argv], inheritWorkspaceLease({ - stdio: ["inherit", "inherit", "inherit"], - })); - // A cancellation aimed at this process's PID (a CI runner stopping the - // step, `kill `) must stop the tests too, not orphan them. Ctrl-C in a - // terminal already reaches both through the foreground process group. - for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { - process.on(signal, () => proc.kill(signal)); + const root = resolve(import.meta.dir, ".."); + // Cancellation covers preparation as well as tests; an interrupted build is + // never retried and cannot launch tests, even if it was just exiting zero. + let cancelled: NodeJS.Signals | undefined; + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + let owned: ReturnType | undefined; + const stopping: Promise[] = []; + const stop = (signal: NodeJS.Signals) => { const pending = owned?.stop(signal); if (pending) { stopping.push(pending); void pending.catch(() => {}); } }; + const started = (child: ReturnType) => { owned = child; if (cancelled) stop(cancelled); }; + const listeners = signals.map(signal => () => { cancelled = signal; stop(signal); }); + signals.forEach((signal, index) => process.on(signal, listeners[index]!)); + let dispose = () => {}; + let result = 1; + try { + const paths = testPaths(argv); + const borrowed = !unsupported && workspaceTestAccess() === "write" && delegatedSpeech && acceptsSpeechDelegation(root, paths); + if (!borrowed) clearInvocation(); + if (fullPreparation && workspaceTestAccess() !== "write") throw new Error("Read-only verification cannot prepare workspace output"); + const directories = runtimeBuildSelection(root, process.cwd(), paths); + if (!help && !borrowed && !unsupported && workspaceTestAccess() === "write" && (fullPreparation || directories.length)) { + const preparation = await prepareInvocation(root, fullPreparation ? undefined : directories, started); + dispose = preparation.dispose; + if (preparation.code !== 0) { result = preparation.code; throw new Error(`Test invocation preparation failed (${result})`); } + } + if (!cancelled) { + const tests = invocationChild([process.execPath, "test", "--preload", join(import.meta.dir, "test-network-preload.ts"), ...argv], { stdin: "inherit" }); + started(tests); + await tests.ready; + const relay = async (stream: ReadableStream, channel: "stdout" | "stderr") => { for await (const chunk of stream) process[channel].write(chunk); }; + const joined = await Promise.allSettled([tests.exited, relay(tests.stdout, "stdout"), relay(tests.stderr, "stderr")]); + await tests.finish(); + result = joined[0].status === "fulfilled" ? joined[0].value : 1; + if (joined.some(item => item.status === "rejected")) result = result || 1; + if (tests.signalCode) result = 128 + (constants.signals[tests.signalCode] ?? 0); + } + } catch (error) { console.error(error); } + finally { + const stopped = await Promise.allSettled(stopping); + if (stopped.some(item => item.status === "rejected")) { console.error("Invocation child cleanup failed", stopped); result = result || 1; } + dispose(); + signals.forEach((signal, index) => process.off(signal, listeners[index]!)); } - const code = await proc.exited; - process.exit(proc.signalCode ? 128 + (constants.signals[proc.signalCode] ?? 0) : code); + process.exit(cancelled ? 128 + (constants.signals[cancelled] ?? 0) : result); } diff --git a/scripts/unit-workers.ts b/scripts/unit-workers.ts index 4ec29d75d..d11f539ec 100644 --- a/scripts/unit-workers.ts +++ b/scripts/unit-workers.ts @@ -195,12 +195,10 @@ export async function runUnitWorkers(root: string, files: readonly string[], chr const { dev, ino } = statSync(join(workspace, "node_modules/typescript/package.json")); return { worker: index + 1, workspace: relative(root, workspace), files, checkout, dependency: { dev, ino } }; }), null, 2)); - await runCommands(workers.map((worker, index) => ({ name: `unit worker ${index + 1} private package build`, - argv: [process.execPath, "run", "--cwd", worker.root, "build"] })), root, 2); for (const worker of workers) mkdirSync(join(worker.root, "tmp/maintenance-ledger"), { recursive: true, mode: 0o700 }); await runCommands(workers.map((worker, index) => ({ name: `complete unit worker ${index + 1}/${workers.length}`, argv: ["unshare", "--user", "--map-current-user", "--keep-caps", "--mount", "bash", join(root, "scripts/unit-worker-entry.sh"), - join(worker.root, "tmp/maintenance-ledger"), process.execPath, "run", "--cwd", worker.root, "test", ...worker.files.map(file => join(worker.root, file)), + join(worker.root, "tmp/maintenance-ledger"), process.execPath, "run", "--cwd", worker.root, "test", "--internal-full-preparation", ...worker.files.map(file => join(worker.root, file)), "--reporter=junit", "--reporter-outfile=tmp/unit-report.xml", "--timings=tmp/unit-timings.json", "--update-timings"], env: { BRAIN_REQUIRE_CHROME: "1", ...(chrome ? { PUPPETEER_EXECUTABLE_PATH: chrome } : {}) } })), root, 2); const reports = workers.map(worker => { diff --git a/scripts/workspace-lease.d.mts b/scripts/workspace-lease.d.mts index fa552ea60..3c31ab3d0 100644 --- a/scripts/workspace-lease.d.mts +++ b/scripts/workspace-lease.d.mts @@ -1,3 +1,19 @@ export function ensureWorkspaceLease(root: string, mode: "read" | "write", entry?: "test"): Promise; export function inheritWorkspaceLease(options: T, defaultStderr?: "pipe" | "inherit", pipeOnly?: boolean): T; export function workspaceTestAccess(): "read" | "write"; +export function processIdentity(pid: number): { parent: number; start: string } | undefined; +export function liveAncestor(ownerPid: number, ownerStart: string): boolean; +export interface WorkspaceLeaseIdentity { + mode: "read" | "write"; + root: string; + rootDev: number; + rootIno: number; + dev: number; + ino: number; + ownerPid: number; + ownerFd: number; + ownerStart: string; +} +export function workspaceLeaseIdentity(root: string): WorkspaceLeaseIdentity | undefined; +export function advanceOutputGeneration(root: string): string; +export function outputGeneration(root: string): string | undefined; diff --git a/scripts/workspace-lease.mjs b/scripts/workspace-lease.mjs index ac1abdb47..ce8cc4d62 100644 --- a/scripts/workspace-lease.mjs +++ b/scripts/workspace-lease.mjs @@ -1,5 +1,6 @@ /** Coordinate development commands which share mutable workspace output. */ -import { fstatSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { closeSync, constants as fsConstants, fstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { constants } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -41,6 +42,77 @@ function ancestorOwnsLease(lease) { return false; } +/** Linux process lifetime, including PID reuse and zombie rejection. */ +export function processIdentity(pid) { + try { + if (!Number.isSafeInteger(pid) || pid < 1) return; + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + if (["Z", "X"].includes(fields[0])) return; + return { parent: Number(fields[1]), start: fields[19] }; + } catch { /* A dead owner has no authority. */ } +} + +export function liveAncestor(ownerPid, ownerStart) { + if (process.platform !== "linux" || typeof ownerStart !== "string" || !/^\d+$/.test(ownerStart)) return false; + let pid = process.pid; + for (let depth = 0; pid > 0 && depth < 128; depth++) { + const identity = processIdentity(pid); + if (!identity) return false; + if (pid === ownerPid) return identity.start === ownerStart; + pid = identity.parent; + } + return false; +} + +/** Strict invocation admission, in addition to ordinary inherited lock transport. */ +export function workspaceLeaseIdentity(root) { + try { + const lease = inheritedLease(); + if (!lease || !liveAncestor(lease.ownerPid, lease.ownerStart)) return; + const path = realpathSync(root), st = statSync(path), lock = statSync(lockPath(path)); + if (lease.root !== path || lease.rootDev !== st.dev || lease.rootIno !== st.ino || lock.dev !== lease.dev || lock.ino !== lease.ino) return; + const held = statSync(`/proc/${lease.ownerPid}/fd/${lease.ownerFd}`); + if (!held.isFile() || held.dev !== lock.dev || held.ino !== lock.ino) return; + const info = readFileSync(`/proc/${lease.ownerPid}/fdinfo/${lease.ownerFd}`, "utf8"); + // The path/inode alone survives release. Require an actual held flock and + // its original owner lifetime; a readable marker never grants write access. + const mode = lease.mode === "write" ? "WRITE" : "(?:READ|WRITE)"; + if (!new RegExp(`^lock:\\s+\\d+: FLOCK\\s+ADVISORY\\s+${mode}\\s+${lease.ownerPid}\\s+`, "m").test(info)) return; + return { mode: lease.mode, root: path, rootDev: st.dev, rootIno: st.ino, + dev: lock.dev, ino: lock.ino, ownerPid: lease.ownerPid, ownerFd: lease.ownerFd, ownerStart: lease.ownerStart }; + } catch { /* Unsupported/missing/replaced ownership cannot attest a build. */ } +} + +const generationPath = root => join(root, "tmp", "workspace-operation", "output.generation"); +const generationShape = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; + +/** + * Revoke every earlier claim on the shared package output before mutating it. + * Each writer replaces the token first, so a failed, cancelled or nested write + * still invalidates an outer invocation's attested build. This is revocation + * only: a matching token never makes a build fresh by itself. + */ +export function advanceOutputGeneration(root) { + const path = generationPath(resolve(root)), generation = randomUUID(), staged = `${path}.${process.pid}.${generation}`; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(staged, generation, { flag: "wx", mode: 0o600 }); + renameSync(staged, path); + return generation; +} + +export function outputGeneration(root) { + try { + const fd = openSync(generationPath(resolve(root)), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK); + try { + const st = fstatSync(fd); + if (!st.isFile() || st.size !== 36) return; + const value = readFileSync(fd, "utf8"); + return generationShape.test(value) ? value : undefined; + } finally { closeSync(fd); } + } catch { /* A missing or replaced token matches no attested build. */ } +} + function sameFile(fd, path) { try { const held = fstatSync(fd), wanted = statSync(path); @@ -174,7 +246,9 @@ async function enter(root, mode, lifetimeFd, executable, args) { } if (fd === undefined) throw new Error("Workspace lock descriptor was not inherited from flock"); const held = fstatSync(fd); - const owner = { ownerPid: process.pid, ownerFd: fd }; + const canonical = realpathSync(root), scope = statSync(canonical); + const owner = { ownerPid: process.pid, ownerFd: fd, ownerStart: processIdentity(process.pid)?.start, + root: canonical, rootDev: scope.dev, rootIno: scope.ino }; process.env[MARKER] = JSON.stringify({ mode, fd, dev: held.dev, ino: held.ino, ...owner }); const { spawn } = await import("node:child_process"); const { Socket } = await import("node:net"); diff --git a/tests/experiment-tier.test.ts b/tests/experiment-tier.test.ts index 821c051a4..c356c8f65 100644 --- a/tests/experiment-tier.test.ts +++ b/tests/experiment-tier.test.ts @@ -16,7 +16,7 @@ import { } from "../scripts/test-experiments"; const ROOT = join(import.meta.dir, ".."); -const SCRIPTS = ["test.ts", "test-experiments.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", +const SCRIPTS = ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-experiments.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]; const passing = (name: string) => `import { expect, test } from "bun:test";\ntest(${JSON.stringify(name)}, () => expect(1).toBe(1));\n`; diff --git a/tests/helpers/test-invocation-fixture.ts b/tests/helpers/test-invocation-fixture.ts new file mode 100644 index 000000000..ac63308c7 --- /dev/null +++ b/tests/helpers/test-invocation-fixture.ts @@ -0,0 +1,51 @@ +/** Real compiler/output/Node consumers in private, byte-owned checkouts. */ +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +const source = resolve(import.meta.dir, "../.."); +export function invocationFixture(parent = tmpdir()) { + const root = mkdtempSync(join(parent, "odysseus-test-invocation-")); + function write(path: string, bytes: string) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), bytes); } + write("package.json", JSON.stringify({ name: "odysseus-invocation", private: true, type: "module", scripts: { test: "bun scripts/test.ts --timeout 30000" } })); + write("bun.lock", "{}\n"); + write(".gitignore", "node_modules/\npackages/*/dist/\ntmp/\n"); + write("tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); + for (const file of ["build.ts", "clean.ts", "build-execution.ts", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs", "test.ts", "test-invocation.ts", "test-invocation-child.ts", "runtime-test-builds.ts", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "unit-worker-entry.sh"]) { + write(`scripts/${file}`, readFileSync(join(source, "scripts", file), "utf8")); + } + for (const file of ["packages/core/tests/cli-harness.ts", "packages/ui-react/tests/fixtures/runtime-package-build.ts", "packages/ui-react/tests/fixtures/runtime-hook-phases.ts"]) write(file, readFileSync(join(source, file), "utf8")); + cpSync(join(source, "node_modules/typescript"), join(root, "node_modules/typescript"), { recursive: true }); + write("node_modules/.bin/tsc", `#!${process.execPath}\nconst child=Bun.spawn([process.execPath,new URL("../typescript/bin/tsc",import.meta.url).pathname,...process.argv.slice(2)],{stdout:"inherit",stderr:"inherit"});process.exitCode=await child.exited;\n`); + chmodSync(join(root, "node_modules/.bin/tsc"), 0o755); + for (const name of ["ithaca", "troy"]) { + write(`packages/${name}/package.json`, JSON.stringify({ name: `@odysseus/${name}`, version: "1.0.0", type: "module" })); + write(`packages/${name}/src/index.ts`, 'export const cargo = 1; export const port = (value?: { name?: string }) => value?.name ?? "Ithaca";\n'); + write(`packages/${name}/tsconfig.build.json`, JSON.stringify({ extends: "../../tsconfig.json", compilerOptions: { outDir: "dist", rootDir: "src", declaration: true }, include: ["src"] })); + } + // The production coordinator with an actual child, and continuous real flock. + write("owner.ts", `import {ensureWorkspaceLease,inheritWorkspaceLease} from "./scripts/workspace-lease.mjs"; +import {clearInvocation,prepareInvocation} from "./scripts/test-invocation"; +const code=await ensureWorkspaceLease(import.meta.dir,"write");if(code!==undefined)process.exit(code); +clearInvocation();let active;let cancelled=false; +for(const signal of ["SIGINT","SIGTERM","SIGHUP"])process.on(signal,()=>{cancelled=true;active?.stop ? active.stop(signal) : active?.kill(signal)}); +const preparation=await prepareInvocation(import.meta.dir,(process.env.ONLY||"ithaca,troy").split(","),child=>{active=child}); +try { if(preparation.code||cancelled)process.exitCode=preparation.code||1;else { +active=Bun.spawn([process.execPath,"probe.ts"],inheritWorkspaceLease({stdio:["inherit","inherit","inherit"]}));process.exitCode=await active.exited; +}}finally{preparation.dispose()} +`); + return { root, write }; +} + +export const consumerProbe = `import {expect} from "bun:test"; +import {readFileSync,writeFileSync,openSync,closeSync,renameSync,mkdirSync,cpSync,rmSync} from "node:fs"; +import {join} from "node:path"; +import {createHash} from "node:crypto"; +import {runtimePackageBuild} from "./packages/ui-react/tests/fixtures/runtime-package-build"; +import {runtimeHookPhases} from "./packages/ui-react/tests/fixtures/runtime-hook-phases"; +import {invocationBuild} from "./scripts/test-invocation"; +const root=import.meta.dir; +const hook=()=>runtimePackageBuild(root,["ithaca"],runtimeHookPhases("odysseus")); +const source=(marker)=>writeFileSync(join(root,"packages/ithaca/src/index.ts"),"export const cargo = "+marker+";\\n"); +const consume=()=>{const child=Bun.spawnSync(["node","--input-type=module","-e",'import {cargo} from '+JSON.stringify(join(root,"packages/ithaca/dist/index.js"))+';console.log(cargo)'],{stdout:"pipe",stderr:"pipe"});expect(child.exitCode,new TextDecoder().decode(child.stderr)).toBe(0);return Number(new TextDecoder().decode(child.stdout).trim());}; +const replaceRecord=(edit)=>{const locator=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);const path='/proc/'+locator.ownerPid+'/fd/'+locator.fd;const record=JSON.parse(readFileSync(path,'utf8'));edit(record);const bytes=JSON.stringify(record);const fd=openSync(path,'w');try{writeFileSync(fd,bytes)}finally{closeSync(fd)}locator.sha256=createHash('sha256').update(bytes).digest('hex');process.env.BRAIN_TEST_BUILD_AUTHORITY=JSON.stringify(locator);}; +`; diff --git a/tests/helpers/unit-worker-fixture.ts b/tests/helpers/unit-worker-fixture.ts index c64465b8c..3d7096f56 100644 --- a/tests/helpers/unit-worker-fixture.ts +++ b/tests/helpers/unit-worker-fixture.ts @@ -8,7 +8,6 @@ export function unitWorkerFixture(options: { failBuild?: boolean; diagnostics?: mkdirSync(join(root, "tmp")); const lease = join(source, "scripts/workspace-lease.mjs"); const receipt = join(source, "scripts/build-receipt.ts"); - const guard = join(source, "scripts/test-network-child-preload.ts"); const budget = join(source, "packages/ui-sdk/src/server/maintenance-temp-budget.ts"); const write = (file: string, content: string) => { mkdirSync(dirname(join(root, file)), { recursive: true }); @@ -17,6 +16,7 @@ export function unitWorkerFixture(options: { failBuild?: boolean; diagnostics?: write("scripts/unit-worker-entry.sh", readFileSync(join(source, "scripts/unit-worker-entry.sh"), "utf8")); write(".gitignore", "node_modules/\ntmp/\ndist/\n.vitest-attachments/\n"); write("package.json", JSON.stringify({ name: "odysseus-unit-fixture", type: "module", scripts: { build: "bun scripts/build.ts", test: "bun scripts/test.ts" } })); + write("packages/raft/package.json", JSON.stringify({ name: "@odysseus/raft", version: "1.0.0" })); write("packages/raft/README.md", "Odysseus builds the raft.\n"); // This byte-owned stand-in tests dependency copying, not a compiler version. write("node_modules/typescript/package.json", '{"name":"odysseus-dependency-fixture"}\n'); @@ -63,11 +63,11 @@ const {basename,join}=await import("node:path");const {existsSync}=await import( const shared=${JSON.stringify(join(root,"tmp"))};writeFileSync(join(shared,"built-"+basename(process.cwd())),"ready"); const stop=Date.now()+5000;while((!existsSync(join(shared,"built-1"))||!existsSync(join(shared,"built-2")))&&Date.now() `tests/${name}.test.ts`); for (const [index, file] of files.entries()) write(file, `import {expect,test} from "bun:test"; import {existsSync,mkdirSync,readFileSync,statSync,writeFileSync} from "node:fs"; @@ -86,6 +86,10 @@ test("${index === 0 ? "Odysseus" : "Penelope"} owns build, lease and maintenance expect(existsSync("dist/ready.txt"),"PRIVATE_UNIT_BUILD_READY").toBe(true); expect(readFileSync("dist/ready.txt","utf8")).toBe("Odysseus builds the raft."); const lease=JSON.parse(process.env.BRAIN_WORKSPACE_LEASE!); + const prepared=JSON.parse(readFileSync("dist/lease.json","utf8")); + expect(prepared.lease.ownerPid,"PREPARATION_AND_TEST_SHARE_LIVE_LEASE").toBe(lease.ownerPid); + expect(prepared.lease.ownerStart).toBe(lease.ownerStart); + expect(prepared.nonce,"PREPARATION_AND_TEST_SHARE_INVOCATION").toBe(process.env.BRAIN_TEST_INVOCATION_NONCE); expect(lease.ino,"PRIVATE_UNIT_OUTPUT_LEASE").toBe(statSync("tmp/workspace-operation/output.lock").ino); const git=Bun.spawnSync(["git","rev-parse","--path-format=absolute","--git-common-dir"],{stdout:"pipe",stderr:"pipe"});expect(git.exitCode).toBe(0); const parent=dirname(new TextDecoder().decode(git.stdout).trim()),shared=join(parent,"tmp/shared");mkdirSync(shared,{recursive:true}); diff --git a/tests/local-checks.test.ts b/tests/local-checks.test.ts index 308909c48..826aaf61a 100644 --- a/tests/local-checks.test.ts +++ b/tests/local-checks.test.ts @@ -313,7 +313,7 @@ function importFixture() { const dir = fixture(); const root = join(import.meta.dir, ".."); mkdirSync(join(dir, "scripts")); - for (const helper of ["test.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const helper of ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(root, "scripts", helper), join(dir, "scripts", helper)); } mkdirSync(join(dir, "packages/core/tests"), { recursive: true }); diff --git a/tests/runtime-build-selection.test.ts b/tests/runtime-build-selection.test.ts new file mode 100644 index 000000000..17adb72a5 --- /dev/null +++ b/tests/runtime-build-selection.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { runtimeBuildInventory, runtimeBuildSelection } from "../scripts/runtime-test-builds"; +import { selectBuildPackages } from "../scripts/publishable-packages"; +import { testArgv, testPaths } from "../scripts/test"; +const root = resolve(import.meta.dir, ".."); + +test("every runtime package hook has one inventoried actual forward closure and no boolean bypass", async () => { + const directory = join(root, "packages/ui-react/tests"); + const hooks = readdirSync(directory).filter(name => name.endsWith(".test.ts") && readFileSync(join(directory, name), "utf8").includes("runtimePackageBuild(")); + expect(hooks).toHaveLength(17); + expect(hooks.map(name => `packages/ui-react/tests/${name}`).sort()).toEqual(Object.keys(runtimeBuildInventory).sort()); + for (const file of hooks) { + const text = readFileSync(join(directory, file), "utf8"); + expect(text).not.toMatch(/BRAIN_(?:JOURNAL|ARTIFACT)_DEPENDENCIES_BUILT|BRAIN_AUTH_FIXTURE_BUILT|scripts\/build\.ts/); + const match = /runtimePackageBuild\([^\n]*?, (\[[^\]]+\])/.exec(text); + expect(match, file).not.toBeNull(); + const packages = JSON.parse(match![1]!); + expect(packages, file).toEqual(runtimeBuildInventory[`packages/ui-react/tests/${file}`]); + const child = Bun.spawn([process.execPath, "scripts/build.ts", "--dry-run", "--for", packages.join(",")], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, err).toBe(0); + expect(out.trim().split("\n")).toEqual(selectBuildPackages(root, packages).map(pkg => `Would build packages/${pkg.dir}`)); + } +}); + +test("build selection shares Bun positional parsing without changing test argv", () => { + const full = ["ui-react", "ui-server", "core"]; + for (const args of [["external-speech"], ["packages/ui-react/tests"], ["./packages"], [join(root, "packages/ui-react/tests/external-speech-runtime.test.ts")], ["--", "external-speech"], ["--changed"], ["--shard=1/4"]]) { + const argv = testArgv(args); + expect(selectBuildPackages(root, runtimeBuildSelection(root, root, testPaths(argv))).map(pkg => pkg.dir)).toEqual(selectBuildPackages(root, full).map(pkg => pkg.dir)); + } + for (const args of [["tests/test-script.test.ts", "-t", "external-speech"], ["tests/test-script.test.ts", "--preload", "external-speech"], ["tests/test-script.test.ts", "--timeout", "180000"], ["does-not-exist.test.ts"]]) { + expect(testArgv(args)).toEqual(args); + expect(runtimeBuildSelection(root, root, testPaths(testArgv(args)))).toEqual([]); + } + expect(runtimeBuildSelection(root, root, ["share-draft-runtime"])).toEqual(["ui-sdk"]); + expect(runtimeBuildSelection(root, root, ["haiku-model-runtime"])).toEqual(["ui-react"]); +}); diff --git a/tests/test-invocation-child.test.ts b/tests/test-invocation-child.test.ts new file mode 100644 index 000000000..16f9ead15 --- /dev/null +++ b/tests/test-invocation-child.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { invocationChild } from "../scripts/test-invocation-child"; +import { processIdentity } from "../scripts/workspace-lease.mjs"; + +test("cancellation before the anchor handshake never launches the command", async () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-anchor-start-")); + const marker = join(root, "launched"); + const child = invocationChild([process.execPath, "-e", `require("fs").writeFileSync(${JSON.stringify(marker)},"launched");`]); + try { + const stopped = child.stop("SIGTERM"); + await expect(child.ready).rejects.toThrow("before command startup"); + await Promise.all([stopped, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(await child.exited).not.toBe(0); + expect(existsSync(marker), "CANCEL_BEFORE_LAUNCH_HAS_NO_COMMAND").toBe(false); + expect(child.cancelled).toBe(true); + } finally { await child.finish(); rmSync(root, { recursive: true, force: true }); } +}); + +test("normal completion preserves command status and stops descendants after both output EOFs", async () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-anchor-finish-")); + const marker = join(root, "descendant"); + const child = invocationChild(["node", "-e", ` +const fs=require("node:fs"), {spawn}=require("node:child_process"); +const c=spawn("node",["-e","setInterval(()=>{},1000)"],{stdio:"ignore"});c.unref(); +fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify({pid:c.pid,start:fs.readFileSync("/proc/"+c.pid+"/stat","utf8").split(") ")[1].split(" ")[19]})); +console.log("Odysseus stdout");console.error("Penelope stderr");process.exitCode=21;`]); + let descendant: { pid: number; start: string } | undefined; + try { + await child.ready; + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + descendant = JSON.parse(readFileSync(marker, "utf8")); + expect(code, "ACTUAL_COMMAND_EXIT_CODE").toBe(21); + expect(stdout).toBe("Odysseus stdout\n"); expect(stderr).toBe("Penelope stderr\n"); + expect(child.resourceUsage()?.cpuTime.user).toBeGreaterThan(0); + expect(processIdentity(descendant!.pid)?.start, "DESCENDANT_OUTLIVES_COMMAND_AND_OUTPUT").toBe(descendant!.start); + await child.finish(); + const end = Date.now() + 1_000; + while (processIdentity(descendant!.pid)?.start === descendant!.start && Date.now() < end) await Bun.sleep(10); + expect(processIdentity(descendant!.pid)?.start, "FINISH_STOPS_OWNED_GROUP").not.toBe(descendant!.start); + expect(child.cancelled).toBe(false); expect(child.signalCode).toBeNull(); expect(await child.exited).toBe(21); + } finally { + await child.stop("SIGTERM"); await child.finish(); + if (descendant && processIdentity(descendant.pid)?.start === descendant.start) process.kill(descendant.pid, "SIGKILL"); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/test-invocation-lifetime.test.ts b/tests/test-invocation-lifetime.test.ts new file mode 100644 index 000000000..958f89b90 --- /dev/null +++ b/tests/test-invocation-lifetime.test.ts @@ -0,0 +1,229 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { consumerProbe, invocationFixture } from "./helpers/test-invocation-fixture"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { const value = invocationFixture(); roots.push(value.root); return value; } +async function until(check: () => boolean, label: string) { + const end = Date.now() + 10_000; + while (!check() && Date.now() < end) await Bun.sleep(10); + expect(check(), label).toBe(true); +} +function stopOwnedGroup(pid: number) { + try { process.kill(-pid, "SIGKILL"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } +} +function start(root: string, args = ["owner.ts"]) { + const child = Bun.spawn([process.execPath, ...args], { cwd: root, env: { ...process.env, ONLY: "ithaca" }, stdout: "pipe", stderr: "pipe" }); + let output = ""; + const drain = async (stream: ReadableStream) => { for await (const bytes of stream) output += new TextDecoder().decode(bytes); }; + const done = Promise.all([child.exited, drain(child.stdout), drain(child.stderr)]).then(([code]) => ({ code, output })); + return { child, done, output: () => output }; +} + +test("preparation waits for real delayed stdout and stderr EOF after builder exit", async () => { + const { root, write } = fixture(); + const compiler = readFileSync(join(root, "node_modules/.bin/tsc"), "utf8"); + write("node_modules/.bin/tsc", compiler + ` +Bun.spawn([process.execPath,"hold.ts"],{stdout:"inherit",stderr:"inherit"});process.exit(process.exitCode||0); +`); + write("hold.ts", `import {existsSync,writeFileSync} from "node:fs";writeFileSync("tmp/eof-holder",String(process.pid));const end=Date.now()+10000;while(!existsSync("tmp/release-eof")&&Date.now() running.output().includes('"event":"build-exit"'), "BUILDER_EXIT_OBSERVED"); + expect(running.output(), "NO_EARLY_AUTHORITY_AT_EXIT").not.toContain('"event":"build-attested"'); + expect(running.output()).not.toContain("TESTS_STARTED"); + writeFileSync(join(root, "tmp/release-eof"), "release"); + const result = await running.done; expect(result.code, result.output).toBe(0); + expect(result.output).toContain("delayed stdout"); expect(result.output).toContain("delayed stderr"); + for (const channel of ["stdout", "stderr"]) expect(result.output.indexOf(`"event":"${channel}-complete"`)).toBeLessThan(result.output.indexOf('"event":"build-attested"')); + } finally { running.child.kill(); await running.done; } +}); + +test("cancellation during actual preparation prevents tests and cleans the owned compiler", async () => { + const { root, write } = fixture(); + write("node_modules/.bin/tsc", `#!${process.execPath}\nimport {writeFileSync} from "node:fs";writeFileSync("tmp/compiler-pid",String(process.pid));await Bun.sleep(30000);process.exit(0);\n`); + write("sentinel.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("TESTS_STARTED"));'); + const running = start(root, ["run", "test", "--internal-full-preparation", "sentinel.test.ts"]); + try { + await until(() => existsSync(join(root, "tmp/compiler-pid")), "COMPILER_STARTED"); + const pid = Number(readFileSync(join(root, "tmp/compiler-pid"), "utf8")); + running.child.kill("SIGTERM"); + const result = await running.done; expect(result.code, result.output).not.toBe(0); + expect(result.output).not.toContain("TESTS_STARTED"); expect(result.output).not.toContain('"event":"build-attested"'); + await until(() => !existsSync(`/proc/${pid}`) || /\) Z /.test(readFileSync(`/proc/${pid}/stat`, "utf8")), "OWNED_COMPILER_STOPPED"); + // The same checkout is admitted by a fresh writer after cancellation. + const lock = Bun.spawn([process.execPath, "-e", 'import {ensureWorkspaceLease} from "./scripts/workspace-lease.mjs";const code=await ensureWorkspaceLease(process.cwd(),"write");if(code!==undefined)process.exit(code);console.log("NEW_WRITER_ADMITTED");'], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([lock.exited, new Response(lock.stdout).text(), new Response(lock.stderr).text()]); + expect(code, out + err).toBe(0); expect(out).toContain("NEW_WRITER_ADMITTED"); + } finally { running.child.kill(); await running.done; } +}); + +test("abrupt invocation owner death revokes receipt while an explicitly owned descendant survives", async () => { + const { root, write } = fixture(); + write("survivor.ts", consumerProbe + `const end=Date.now()+10000;while(!require("fs").existsSync("tmp/owner-died")&&Date.now() existsSync(join(root, "tmp/survivor-pid")), "SURVIVOR_STARTED"); + survivor = Number(readFileSync(join(root, "tmp/survivor-pid"), "utf8")); + const result = await running.done; expect(result.code).not.toBe(0); + writeFileSync(join(root, "tmp/owner-died"), "release"); + await until(() => existsSync(join(root, "tmp/survivor-done")), "SURVIVOR_ACTUAL_FALLBACK: " + (existsSync(join(root, "tmp/survivor.log")) ? readFileSync(join(root, "tmp/survivor.log"), "utf8") : "")); + expect(readFileSync(join(root, "tmp/survivor-done"), "utf8")).toBe("done"); + } finally { + running.child.kill(); await running.done; + if (survivor) stopOwnedGroup(survivor); + } +}); + +test("a real receipt open failure removes only owned scratch and leaves hooks rebuilding", async () => { + const { root, write } = fixture(); + write("receipt-fault.ts", `import fs from "node:fs";import {mock} from "bun:test"; +const open=fs.openSync;let injected=false; +mock.module("node:fs",()=>({...fs,openSync:(path,flags,...args)=>{ + if(!injected&&typeof path==="string"&&path.includes("/brain-test-invocation-")&&path.endsWith("/receipt")&&!(flags&(fs.constants.O_WRONLY|fs.constants.O_RDWR))){ + injected=true;fs.writeFileSync(${JSON.stringify(join(root, "scratch-path"))},path);fs.chmodSync(path,0); + try{return open(path,flags,...args)}catch(error){fs.writeFileSync(${JSON.stringify(join(root, "scratch-error"))},error.code);throw error} + } + return open(path,flags,...args); +}}));`); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeUndefined();source(2);await hook();expect(consume(),"RECEIPT_FAILURE_FALLBACK_REACHES_CONSUMER").toBe(2);`); + const running = start(root, ["--preload", join(root, "receipt-fault.ts"), "owner.ts"]); + try { + const result = await running.done; + expect(existsSync(join(root, "scratch-error")), result.output).toBe(true); + expect(readFileSync(join(root, "scratch-error"), "utf8"), "REAL_KERNEL_RECEIPT_OPEN_ERROR").toBe("EACCES"); + const scratch = readFileSync(join(root, "scratch-path"), "utf8"); + expect(result.code, "RECEIPT_FAILURE_FALLBACK_REACHES_CONSUMER: " + result.output).toBe(0); + expect(existsSync(scratch), "OWNED_FAILED_RECEIPT_REMOVED").toBe(false); + expect(existsSync(join(scratch, "..")), "OWNED_EMPTY_SCRATCH_REMOVED").toBe(false); + } finally { + running.child.kill(); await running.done; + if (existsSync(join(root, "scratch-path"))) rmSync(join(readFileSync(join(root, "scratch-path"), "utf8"), ".."), { recursive: true, force: true }); + } +}); + +for (const damage of ["unrelated-entry", "replaced-receipt"] as const) test(`receipt cleanup retains ${damage} and fails before tests`, async () => { + const { root, write } = fixture(); + write("receipt-fault.ts", `import fs from "node:fs";import {mock} from "bun:test"; +const open=fs.openSync;let injected=false; +mock.module("node:fs",()=>({...fs,openSync:(path,flags,...args)=>{ + if(!injected&&typeof path==="string"&&path.includes("/brain-test-invocation-")&&path.endsWith("/receipt")&&!(flags&(fs.constants.O_WRONLY|fs.constants.O_RDWR))){ + injected=true;fs.writeFileSync(${JSON.stringify(join(root, "scratch-path"))},path); + ${damage === "unrelated-entry" ? 'fs.writeFileSync(path+".unrelated","Penelope retains this entry");fs.chmodSync(path,0);' : 'fs.renameSync(path,path+".original");fs.writeFileSync(path,"Penelope retains this replacement");'} + } + return open(path,flags,...args); +}}));`); + write("probe.ts", 'console.log("TESTS_STARTED");'); + const running = start(root, ["--preload", join(root, "receipt-fault.ts"), "owner.ts"]); + try { + const result = await running.done; + expect(result.code, result.output).not.toBe(0); + expect(result.output).toContain(damage === "unrelated-entry" ? "ENOTEMPTY" : "Replaced invocation receipt; retained scratch"); + expect(result.output).not.toContain("TESTS_STARTED"); + expect(result.output).not.toContain('"event":"build-attested"'); + const path = readFileSync(join(root, "scratch-path"), "utf8"); + if (damage === "unrelated-entry") { + expect(existsSync(path), "OWNED_RECEIPT_REMOVED_DESPITE_UNRELATED_ENTRY").toBe(false); + expect(readFileSync(path + ".unrelated", "utf8"), "UNRELATED_ENTRY_RETAINED").toBe("Penelope retains this entry"); + } else { + expect(readFileSync(path, "utf8"), "REPLACEMENT_INODE_RETAINED").toBe("Penelope retains this replacement"); + } + } finally { + running.child.kill(); await running.done; + // These injected entries belong to this control, never production cleanup. + if (existsSync(join(root, "scratch-path"))) rmSync(join(readFileSync(join(root, "scratch-path"), "utf8"), ".."), { recursive: true, force: true }); + } +}); + +test("cancelling a nested preparation stops its compiler while the outer invocation stays live", async () => { + const { root, write } = fixture(); + write("nested.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("NESTED_TESTS_STARTED"));'); + write("probe.ts", consumerProbe + ` +writeFileSync("node_modules/.bin/tsc",'#!'+process.execPath+'\\nimport {writeFileSync} from "node:fs";writeFileSync("tmp/nested-compiler",String(process.pid));await Bun.sleep(30000);'); +const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"pipe",stderr:"pipe"}); +const output=Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text()]); +const end=Date.now()+10000;while(!require("fs").existsSync("tmp/nested-compiler")&&Date.now(){try{return !/\\) [ZX] /.test(readFileSync('/proc/'+compiler+'/stat','utf8'))}catch{return false}};while(live()&&Date.now() { + const { root, write } = fixture(); + write("expected", "1"); + const watched = consumerProbe + `import {test} from "bun:test";test("watch consumer",async()=>{expect(invocationBuild(root,["ithaca"]),"WATCH_NEVER_BORROWS_AUTHORITY").toBeUndefined();await hook();const marker=consume();expect(marker,"WATCH_RERUN_FRESH_CONSUMER").toBe(Number(readFileSync("expected","utf8")));console.log("WATCH_CONSUMER",marker);});`; + write("watched.test.ts", watched); + const args = entry === "wrapper" ? ["run", "test", "--watch", "watched.test.ts"] : ["test", "--watch", "--preload", join(root, "scripts/test-network-preload.ts"), "watched.test.ts"]; + write("probe.ts", `import {existsSync} from "node:fs"; +const child=Bun.spawn([process.execPath,...${JSON.stringify(args)}],{stdout:"inherit",stderr:"inherit"});const end=Date.now()+15000;while(!existsSync("tmp/stop-watch")&&Date.now() running.output().includes("WATCH_CONSUMER 1"), "FIRST_WATCH_CONSUMER: " + running.output()); + write("expected", "2"); write("packages/ithaca/src/index.ts", "export const cargo = 2;\n"); + write("watched.test.ts", watched + "\n// Odysseus prepares another voyage.\n"); + await until(() => running.output().includes("WATCH_CONSUMER 2"), "SECOND_WATCH_CONSUMER: " + running.output()); + write("tmp/stop-watch", "stop"); + const result = await running.done; expect(result.code, result.output).toBe(0); expect(result.output).not.toContain("(fail)"); + } finally { write("tmp/stop-watch", "stop"); running.child.kill(); await running.done; } +}); + +for (const phase of ["build", "tests"] as const) for (const cancellation of ["SIGTERM", "SIGKILL", "late-fork"] as const) +test(`nested ${phase} ${cancellation} stops an exited leader's EOF holder while its outer lease stays live`, async () => { + const { root, write } = fixture(); + // Node unref deliberately lets the producer exit while its descendant retains + // both inherited output descriptors. This is the actual wrapper, not stop alone. + write("late.cjs", `const fs=require("node:fs");fs.writeFileSync("tmp/late",JSON.stringify({pid:process.pid,start:fs.readFileSync("/proc/self/stat","utf8").split(") ")[1].split(" ")[19]}));process.on("SIGTERM",()=>{});setInterval(()=>{},1000);`); + write("hold.cjs", `const fs=require("node:fs");fs.writeFileSync("tmp/orphan",JSON.stringify({pid:process.pid,start:fs.readFileSync("/proc/self/stat","utf8").split(") ")[1].split(" ")[19]}));${cancellation === "late-fork" ? 'process.on("SIGTERM",()=>{require("node:child_process").spawn("node",["late.cjs"],{stdio:["ignore",1,2]}).unref()});' : ""}setInterval(()=>{},1000);`); + const spawnHolder = `const {spawn}=require("node:child_process");const c=spawn("node",["hold.cjs"],{stdio:["ignore",1,2]});c.unref();`; + write("produce.cjs", 'const fs=require("node:fs");if(!fs.existsSync("tmp/producer-started")){fs.writeFileSync("tmp/producer-started","once");' + spawnHolder + "}"); + write("nested.test.ts", phase === "build" ? 'import {test} from "bun:test";test("must not start",()=>console.log("NESTED_TESTS_STARTED"));' : + `import {spawn} from "node:child_process";const c=spawn("node",["produce.cjs"],{stdio:["ignore",1,2]});await new Promise(resolve=>c.once("exit",resolve));console.log("TEST_LEADER_EXIT");process.exit(0);`); + write("probe.ts", consumerProbe + ` +import {existsSync} from "node:fs"; +import {processIdentity,workspaceLeaseIdentity} from "./scripts/workspace-lease.mjs"; +${phase === "build" ? `writeFileSync("node_modules/.bin/tsc",readFileSync("node_modules/.bin/tsc","utf8")+'\\nconst holder=Bun.spawn(["node","produce.cjs"],{stdout:"inherit",stderr:"inherit"});await holder.exited;process.exit(process.exitCode||0);'.replaceAll('\\\\n','\\n'));` : ""} +const lease=workspaceLeaseIdentity(root); +const sibling=Bun.spawn(["node","-e","setInterval(()=>{},1000)"],{stdout:"ignore",stderr:"ignore"}); +const siblingStart=processIdentity(sibling.pid)?.start; +const child=Bun.spawn([process.execPath,"scripts/test.ts",${phase === "build" ? '"--internal-full-preparation",' : ""}"nested.test.ts"],{stdout:"pipe",stderr:"pipe"}); +let output="",done=false;const drain=async stream=>{for await(const bytes of stream)output+=new TextDecoder().decode(bytes)}; +const joined=Promise.all([child.exited,drain(child.stdout),drain(child.stderr)]).then(([code])=>{done=true;return code}); +let orphan; +try { + const ready=Date.now()+10000;while((!existsSync("tmp/orphan")||!output.includes(${JSON.stringify(phase === "build" ? '"event":"build-exit"' : "TEST_LEADER_EXIT")}))&&Date.now() { + const { root, write } = invocationFixture(dirname(resolve(import.meta.dir, ".."))); + const reports = join(root, "tmp/reports"), shared = join(root, "tmp/shared"); mkdirSync(shared, { recursive: true }); + const files = ["tests/odysseus.test.ts", "tests/penelope.test.ts"]; + for (const [index, file] of files.entries()) write(file, `import {test,expect} from "bun:test"; +import {readFileSync,writeFileSync,existsSync} from "node:fs"; +import {join} from "node:path"; +import {invocationBuild} from "../scripts/test-invocation"; +import {runtimePackageBuild} from "../packages/ui-react/tests/fixtures/runtime-package-build"; +import {runtimeHookPhases} from "../packages/ui-react/tests/fixtures/runtime-hook-phases"; +test("independent prepared worker ${index}",async()=>{ +const root=process.cwd(),shared=${JSON.stringify(shared)}; +const receipt=invocationBuild(root,["ithaca","troy"]);expect(receipt,"PRIVATE_WORKER_ACTUAL_SUCCESSFUL_AUTHORITY").toBeDefined(); +const own={authority:process.env.BRAIN_TEST_BUILD_AUTHORITY,nonce:process.env.BRAIN_TEST_INVOCATION_NONCE,lease:receipt.lease,build:receipt.build.pid}; +writeFileSync(join(shared,"${index}.json"),JSON.stringify(own)); +await runtimePackageBuild(root,["ithaca"],runtimeHookPhases("private-worker-${index}")); +const end=Date.now()+10000;while(!existsSync(join(shared,"${1-index}.json"))&&Date.now() { const result = Bun.spawnSync(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }); expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); }; + git("init", "-q"); git("add", "."); git("-c", "user.name=Odysseus", "-c", "user.email=odysseus@example.invalid", "commit", "-qm", "Private invocation fixture"); + try { + await runUnitWorkers(root, files, undefined, reports); + const receipts = readdirSync(reports).filter(file => /-build-.*-probe.json$/.test(file)).map(file => JSON.parse(readFileSync(join(reports, file), "utf8"))); + expect(receipts).toHaveLength(4); + expect(receipts.filter(receipt => receipt.mode === "full"), "ONE_ACTUAL_FULL_PREPARATION_PER_WORKER").toHaveLength(2); + for (const receipt of receipts) expect(receipt.success).toBe(true); + const workers = JSON.parse(readFileSync(join(reports, "unit-worker-checkouts.json"), "utf8")); + expect(new Set(workers.map((worker: { dependency: { ino: number } }) => worker.dependency.ino)).size).toBe(2); + for (const worker of workers) expect(existsSync(join(root, worker.workspace)), "PRIVATE_WORKER_CLEANED").toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 60_000); diff --git a/tests/test-invocation.test.ts b/tests/test-invocation.test.ts new file mode 100644 index 000000000..bbf52fde3 --- /dev/null +++ b/tests/test-invocation.test.ts @@ -0,0 +1,202 @@ +import { afterEach, expect, test } from "bun:test"; +import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { consumerProbe, invocationFixture } from "./helpers/test-invocation-fixture"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { const value = invocationFixture(); roots.push(value.root); return value; } +async function run(root: string, args = ["owner.ts"], env: NodeJS.ProcessEnv = {}) { + const child = Bun.spawn([process.execPath, ...args], { cwd: root, env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, output: out + err }; +} +function receipts(root: string) { return readdirSync(join(root, "tmp")).filter(file => /^build-.*-probe.json$/.test(file)).map(file => JSON.parse(readFileSync(join(root, "tmp", file), "utf8"))); } + +test("multiple real hooks consume one completed invocation build", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"]),"LIVE_BUILD_AUTHORITY").toBeDefined();await hook();source(2);await hook();expect(consume(),"MIDRUN_EDIT_WAITS_FOR_NEXT_INVOCATION").toBe(1);`); + const result = await run(root); expect(result.code, result.output).toBe(0); + expect(receipts(root), "ONE_ACTUAL_COMPILER_SET").toHaveLength(1); + expect(result.output.match(/"disposition":"attested"/g)).toHaveLength(4); + write("probe.ts", consumerProbe + `await hook();expect(consume(),"NEXT_INVOCATION_REACHES_CONSUMER").toBe(2);`); + const next = await run(root); expect(next.code, next.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +for (const [name, damage] of Object.entries({ + absent: 'delete process.env.BRAIN_TEST_BUILD_AUTHORITY;', + malformed: 'process.env.BRAIN_TEST_BUILD_AUTHORITY="{";', + nonce: 'process.env.BRAIN_TEST_INVOCATION_NONCE="00000000-0000-0000-0000-000000000000";', + "reused-owner-pid": 'const loc=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);loc.ownerStart="0";process.env.BRAIN_TEST_BUILD_AUTHORITY=JSON.stringify(loc);', + "reused-owner-fd": 'const loc=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);loc.fd=3;process.env.BRAIN_TEST_BUILD_AUTHORITY=JSON.stringify(loc);', + "wrong-root-inode": 'replaceRecord(r=>r.root.ino++);', + "mismatched-lease": 'replaceRecord(r=>r.lease.ownerStart="0");', + "failed-receipt": 'replaceRecord(r=>r.build.success=false);', + "interrupted-receipt": 'replaceRecord(r=>r.build.state="running");', + "missing-package": 'replaceRecord(r=>r.build.packages=[]);', + "missing-compiler": 'replaceRecord(r=>r.build.packages[0].children=[]);', + "wrong-schema": 'replaceRecord(r=>r.version=2);', + "replaced-receipt": 'const loc=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);writeFileSync("/proc/"+loc.ownerPid+"/fd/"+loc.fd,"{}");', +})) test(`actual helper rebuilds after ${name} authority`, async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();source(2);${damage}await hook();expect(consume(),"INVALID_AUTHORITY_REBUILDS_CONSUMER").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("a readable lease descriptor without its own held flock cannot attest a build", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + ` +const lease=JSON.parse(process.env.BRAIN_WORKSPACE_LEASE),fd=openSync("tmp/workspace-operation/output.lock","r"); +const fields=readFileSync("/proc/self/stat","utf8").split(") ")[1].split(" "); +const marker={...lease,fd,ownerFd:fd,ownerPid:process.pid,ownerStart:fields[19]}; +expect(readFileSync("/proc/self/fdinfo/"+fd,"utf8")).not.toContain("FLOCK"); +process.env.BRAIN_WORKSPACE_LEASE=JSON.stringify(marker); +replaceRecord(r=>r.lease={...r.lease,ownerFd:fd,ownerPid:process.pid,ownerStart:fields[19]}); +try{source(2);await hook();expect(consume(),"UNHELD_DESCRIPTOR_CANNOT_SUPPRESS_COMPILER").toBe(2)}finally{closeSync(fd)} +`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("valid smaller closure cannot authorize another package", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["troy"])).toBeDefined();await hook();expect(consume(),"CLOSURE_SUBSET_REBUILDS_CONSUMER").toBe(1);`); + const result = await run(root, ["owner.ts"], { ONLY: "troy" }); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("a live ancestor's other checkout receipt never suppresses the actual consumer build", async () => { + const first = fixture(), second = fixture(); + second.write("packages/ithaca/dist/index.js", "export const cargo=1;\n"); + second.write("probe.ts", consumerProbe + `source(2);await hook();expect(consume(),"CROSS_CHECKOUT_BUILD_REACHES_CONSUMER").toBe(2);`); + first.write("probe.ts", `const child=Bun.spawn([process.execPath,${JSON.stringify(join(second.root, "probe.ts"))}],{cwd:${JSON.stringify(second.root)},stdout:"inherit",stderr:"inherit"});process.exitCode=await child.exited;`); + const result = await run(first.root); expect(result.code, result.output).toBe(0); expect(receipts(second.root)).toHaveLength(1); +}); + +test("normal nested wrapper creates a new invocation beneath the same live lease", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `source(2);const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"inherit",stderr:"inherit",env:{...process.env,PREVIOUS_NONCE:process.env.BRAIN_TEST_INVOCATION_NONCE,PREVIOUS_AUTHORITY:process.env.BRAIN_TEST_BUILD_AUTHORITY}});expect(await child.exited,"NESTED_WRAPPER").toBe(0);`); + write("nested.test.ts", consumerProbe + `import {test} from "bun:test";test("new invocation",async()=>{expect(process.env.BRAIN_TEST_INVOCATION_NONCE,"NEW_INVOCATION_SAME_LEASE").not.toBe(process.env.PREVIOUS_NONCE);expect(consume()).toBe(2);source(3);process.env.BRAIN_TEST_BUILD_AUTHORITY=process.env.PREVIOUS_AUTHORITY;await hook();expect(consume(),"REPLAYED_INVOCATION_REBUILDS_CONSUMER").toBe(3);});`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(3); +}); + +test("legacy bypass flags without authority still compile", async () => { + const { root, write } = fixture(); + write("packages/ithaca/dist/index.js", "export const cargo=0;\n"); + write("probe.ts", consumerProbe + `await hook();expect(consume(),"LEGACY_FLAGS_DO_NOT_SKIP_BUILD").toBe(1);`); + const result = await run(root, ["probe.ts"], { BRAIN_JOURNAL_DEPENDENCIES_BUILT: "1", BRAIN_ARTIFACT_DEPENDENCIES_BUILT: "1", BRAIN_AUTH_FIXTURE_BUILT: "1" }); + expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(1); +}); + +test("actual prebuild compiler failure prevents test execution without retry", async () => { + const { root, write } = fixture(); + write("packages/ithaca/src/index.ts", "export const cargo: string = 1;\n"); + write("sentinel.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("TESTS_STARTED"));'); + const result = await run(root, ["run", "test", "--internal-full-preparation", "sentinel.test.ts"]); + expect(result.code, result.output).not.toBe(0); expect(result.output).not.toContain("TESTS_STARTED"); expect(receipts(root)).toHaveLength(1); + expect(receipts(root)[0].success).toBe(false); +}); + +for (const damage of ["missing", "malformed", "aliased", "wrong-builder-pid", "wrong-build-id", "wrong-closure"] as const) test(`successful preparation with ${damage} diagnostics leaves real hooks rebuilding`, async () => { + const { root, write } = fixture(); + const path = join(root, "scripts/build.ts"), original = readFileSync(path, "utf8"); + write("scripts/build.ts", original + ` +if(process.env.BRAIN_TEST_BUILD_ID){const fs=await import("node:fs");const file="tmp/build-"+process.pid+"-"+process.env.BRAIN_TEST_BUILD_ID+"-probe.json"; +${damage === "missing" ? 'fs.unlinkSync(file);' : damage === "malformed" ? 'fs.writeFileSync(file,"{");' : damage === "aliased" ? 'fs.renameSync(file,file+".saved");fs.symlinkSync(file.split("/").pop()+".saved",file);' : +`const receipt=JSON.parse(fs.readFileSync(file,"utf8"));${damage === "wrong-builder-pid" ? "receipt.pid++;" : damage === "wrong-build-id" ? 'receipt.id="00000000-0000-0000-0000-000000000000";' : "receipt.packages.pop();"}fs.writeFileSync(file,JSON.stringify(receipt));`}} +`); + // The hook is a different real builder and still runs even when both of its + // best-effort diagnostics are damaged too. Exit/output, not probe presence, + // prove actual compilation and the changed emitted consumer. + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeUndefined();source(2);await hook();expect(consume(),"DIAGNOSTIC_FAILURE_REBUILDS_CONSUMER").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); + expect(result.output).toContain('"event":"build-unattested"'); expect(result.output).toContain('"disposition":"built"'); +}); + +test("read-mode lease cannot reuse authority or delete existing output", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `source(2);const lease=JSON.parse(process.env.BRAIN_WORKSPACE_LEASE);lease.mode="read";process.env.BRAIN_WORKSPACE_LEASE=JSON.stringify(lease);expect(invocationBuild(root,["ithaca"])).toBeUndefined();await expect(hook()).rejects.toThrow("Read-only verification cannot rebuild");expect(consume(),"READ_LEASE_PRESERVES_OUTPUT").toBe(1);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(1); +}); + +test("ordinary explicit builders compile even with valid invocation authority", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();source(2);const child=Bun.spawn([process.execPath,"scripts/build.ts","--for","ithaca"],{stdout:"inherit",stderr:"inherit"});expect(await child.exited).toBe(0);expect(consume(),"EXPLICIT_BUILD_STILL_COMPILES").toBe(2); +source(1);await hook();expect(consume(),"EXPLICIT_BUILD_OUTPUT_NEVER_ATTESTED").toBe(1);expect(invocationBuild(root,["ithaca"]),"EXPLICIT_BUILD_REVOKES_OUTER_AUTHORITY").toBeUndefined();`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(3); +}); + +// The outer receipt describes its own successful build, never whatever a later +// writer beneath the same lease left in dist. Both the original and the +// restored source say 1; only the nested writer ever produced 2. +for (const [name, nested] of Object.entries({ + successful: { edit: 'source(2);', exit: '.toBe(0)' }, + failed: { edit: 'writeFileSync(join(root,"packages/ithaca/src/index.ts"),"export const cargo: string = 2;\\n");', exit: '.not.toBe(0)' }, +})) test(`a ${name} nested wrapper build revokes the outer invocation's authority`, async () => { + const { root, write } = fixture(); + write("nested.test.ts", 'import {test} from "bun:test";test("nested",()=>{});'); + write("probe.ts", consumerProbe + ` +const before=invocationBuild(root,["ithaca"]);expect(before).toBeDefined();expect(consume()).toBe(1); +${nested.edit} +const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"inherit",stderr:"inherit"}); +expect(await child.exited,"NESTED_WRAPPER_EXIT")${nested.exit}; +expect(consume(),"NESTED_WRITER_REPLACED_OUTPUT").toBe(2);source(1); +await hook();expect(consume(),"NESTED_WRITER_OUTPUT_NEVER_SERVED").toBe(1); +expect(invocationBuild(root,["ithaca"]),"NESTED_WRITER_REVOKES_OUTER_AUTHORITY").toBeUndefined();`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(3); + expect(result.output, "OUTER_HOOK_NEVER_ATTESTS_REPLACED_OUTPUT").not.toContain('"disposition":"attested"'); +}); + +test("a cancelled nested wrapper build revokes the outer invocation's authority", async () => { + const { root, write } = fixture(); + write("nested.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("NESTED_TESTS_STARTED"));'); + write("probe.ts", consumerProbe + ` +expect(invocationBuild(root,["ithaca"])).toBeDefined();expect(consume()).toBe(1); +const compiler=readFileSync("node_modules/.bin/tsc","utf8"); +writeFileSync("node_modules/.bin/tsc",'#!'+process.execPath+'\\nimport {mkdirSync,writeFileSync} from "node:fs";mkdirSync("packages/ithaca/dist",{recursive:true});writeFileSync("packages/ithaca/dist/index.js","export const cargo = 2;\\\\n");writeFileSync("tmp/nested-partial","written");await Bun.sleep(30000);'); +const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"pipe",stderr:"pipe"}); +const output=Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text()]); +const end=Date.now()+10000;while(!require("fs").existsSync("tmp/nested-partial")&&Date.now() { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined(); +const child=Bun.spawn([process.execPath,"scripts/clean.ts"],{stdout:"inherit",stderr:"inherit"});expect(await child.exited).toBe(0); +expect(require("fs").existsSync(join(root,"packages/ithaca/dist")),"CLEAN_REMOVED_OUTPUT").toBe(false); +await hook();expect(consume(),"CLEANED_OUTPUT_REBUILT").toBe(1);expect(invocationBuild(root,["ithaca"]),"CLEAN_REVOKES_OUTER_AUTHORITY").toBeUndefined();`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +for (const flag of ["--help", "-h"]) test(`${flag} prints usage without preparing even when the build would fail`, async () => { + const { root, write } = fixture(); + write("packages/ithaca/src/index.ts", "export const cargo: string = 1;\n"); + const failing = await run(root, ["run", "test", "--internal-full-preparation", "missing.test.ts"]); + expect(failing.code, "FAILING_BUILDER_CONTROL").not.toBe(0); + const help = await run(root, ["run", "test", "--internal-full-preparation", flag]); + expect(help.code, help.output).toBe(0); expect(help.output, "HELP_REACHES_BUN").toContain("Usage: bun test"); + expect(help.output, "HELP_NEVER_PREPARES").not.toContain('"event":"build-start"'); expect(receipts(root), "HELP_NEVER_BUILDS").toHaveLength(1); +}); + +test("fresh standalone invocations consume effective config alias and installed compiler changes", async () => { + const { root, write } = fixture(); + const { renameSync, symlinkSync, writeFileSync } = await import("node:fs"); + renameSync(join(root, "tsconfig.json"), join(root, "effective.json")); symlinkSync("effective.json", join(root, "tsconfig.json")); + write("consumer.test.ts", consumerProbe + `import {test} from "bun:test";test("fresh consumer",async()=>{await hook();expect(consume(),"STANDALONE_COMPILER_CHANGE_REACHES_CONSUMER").toBe(Number(process.env.EXPECTED));expect(readFileSync(join(root,"packages/ithaca/dist/index.js"),"utf8").includes("?."),"STANDALONE_ALIAS_REACHES_OUTPUT").toBe(process.env.MODERN==="1");});`); + const args = ["run", "test", "--internal-full-preparation", "consumer.test.ts"]; + let result = await run(root, args, { EXPECTED: "1" }); expect(result.code, result.output).toBe(0); + write("effective.json", readFileSync(join(root, "effective.json"), "utf8").replace("ES2019", "ES2022")); + const compiler = join(root, "node_modules/typescript/lib/_tsc.js"), original = readFileSync(compiler, "utf8"); + const anchor = "function emitNumericOrBigIntLiteral(node) {"; expect(original.split(anchor)).toHaveLength(2); + writeFileSync(compiler, original.replace(anchor, anchor + '\n if (node.text === "1") { writeStringLiteral("2"); return; }')); + result = await run(root, args, { EXPECTED: "2", MODERN: "1" }); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("replacing a checkout at the same pathname revokes the live build receipt", async () => { + const { root, write } = fixture(); roots.push(`${root}.previous`); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();renameSync(root,root+".previous");cpSync(root+".previous",root,{recursive:true});source(2);await hook();expect(consume(),"REPLACED_CHECKOUT_REBUILDS_CONSUMER").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); diff --git a/tests/test-script.test.ts b/tests/test-script.test.ts index b993d775a..fe5a18206 100644 --- a/tests/test-script.test.ts +++ b/tests/test-script.test.ts @@ -33,7 +33,7 @@ async function run(args: string[], env: Record = {}) { if (existsSync(script)) { mkdirSync(join(dir, "scripts")); cpSync(script, join(dir, "scripts/test.ts")); - for (const helper of ["test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const helper of ["runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(ROOT, "scripts", helper), join(dir, "scripts", helper)); } // The actual preload also owns cleanup for the shared CLI harness. diff --git a/tests/test-shards.test.ts b/tests/test-shards.test.ts index 81a3fd44d..02ab31ca6 100644 --- a/tests/test-shards.test.ts +++ b/tests/test-shards.test.ts @@ -59,7 +59,7 @@ describe("balanced shards run the root command for real", () => { mkdirSync(join(dir, "scripts")); const manifest = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture", scripts: { test: manifest.scripts.test } })); - for (const script of ["test.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const script of ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(ROOT, "scripts", script), join(dir, "scripts", script)); } mkdirSync(join(dir, "packages/core/tests"), { recursive: true }); diff --git a/tests/unit-workers.test.ts b/tests/unit-workers.test.ts index 190a3d94f..4cac45fe8 100644 --- a/tests/unit-workers.test.ts +++ b/tests/unit-workers.test.ts @@ -122,7 +122,7 @@ const copy=fs.copyFileSync;mock.module("node:fs",()=>({...fs,copyFileSync:(sourc const child = Bun.spawn([process.execPath, "--preload", preload, "-e", ` import {runUnitWorkers} from ${JSON.stringify(join(import.meta.dir, "../scripts/unit-workers.ts"))}; let failure; try { await runUnitWorkers(${JSON.stringify(root)},${JSON.stringify(files)},undefined,${JSON.stringify(reports)}); } catch(error) { failure=error; } - if(!(failure instanceof AggregateError) || !failure.errors.some(error=>String(error).includes("private package build"))) throw Error("ORIGINAL_PREBUILD_FAILURE_RETAINED"); + if(!(failure instanceof AggregateError) || !failure.errors.some(error=>String(error).includes("complete unit worker"))) throw Error("ORIGINAL_PREBUILD_FAILURE_RETAINED"); console.log("COLLECTOR_FAILURE_EXPLICIT",failure.errors.length); `], { stdout: "pipe", stderr: "pipe" }); const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);