diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index 00ac2cff..b021db85 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -149,6 +149,14 @@ if (process.argv.includes("--list")) { process.exit(0); } +// Park the process instead of spinning it. `Effect.runFork` registers NOTHING with the event +// loop — the keep-alive timer lives in `Runtime.makeRunMain`, which we deliberately don't use +// (our shutdown is the bespoke two-signal one below) — and a bun process whose only pending work +// is an unresolved promise BUSY-SPINS rather than blocking. With units running there is usually a +// socket or timer to park on, but in the documented no-op state (no scripts, no plugins) there is +// nothing at all: field report 2026-07-25 had it pinning a full core indefinitely, `strace` +// showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix. +const keepAlive = setInterval(() => {}, 2 ** 31 - 1); const fiber = Effect.runFork(runner(options)); let stopping = false; const shutdown = (signal: string) => { @@ -161,3 +169,4 @@ process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); await Effect.runPromise(Fiber.await(fiber)); +clearInterval(keepAlive); // every unit ended on its own — let the process exit diff --git a/sdk/test/runner-cli.test.ts b/sdk/test/runner-cli.test.ts new file mode 100644 index 00000000..8179953e --- /dev/null +++ b/sdk/test/runner-cli.test.ts @@ -0,0 +1,56 @@ +// The shipped entry point's idle contract: `punktfunk-scripting` with nothing to run must PARK, +// not spin. `Effect.runFork` registers nothing with the event loop (the keep-alive lives in +// `Runtime.makeRunMain`, which runner-cli deliberately doesn't use), and a bun process whose only +// pending work is an unresolved promise busy-loops instead of blocking — so in the runner's own +// documented no-op state (no scripts, no plugins) it pinned a full core indefinitely until +// runner-cli started holding one idle handle. Measured against the real CLI rather than mocked: +// the regression is a property of the process, not of any function in it. +import { afterAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const SDK = path.join(import.meta.dir, ".."); +const ROOT = path.join(SDK, `.runner-cli-fixtures-${process.pid}`); +const SCRIPTS = path.join(ROOT, "scripts"); +const PLUGINS = path.join(ROOT, "plugins"); +fs.mkdirSync(SCRIPTS, { recursive: true }); +fs.mkdirSync(PLUGINS, { recursive: true }); +afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +// Long enough that bun's startup (~0.3 s of CPU, and it does not grow with the window) is a small +// share of what we measure. +const WINDOW_MS = 3_000; +// A spinning runner burns a whole core — one window's worth of CPU in one window. A parked one +// burns its startup and nothing after. Half the window sits far from both, so a contended CI +// runner that only lets the spinner have half a core still fails. +const MAX_CPU_MS = WINDOW_MS / 2; + +test( + "an idle runner parks instead of spinning", + async () => { + const proc = Bun.spawn( + [ + process.execPath, + "src/runner-cli.ts", + "--scripts", + SCRIPTS, + "--plugins", + PLUGINS, + ], + { cwd: SDK, stdout: "pipe", stderr: "pipe" }, + ); + await Bun.sleep(WINDOW_MS); + proc.kill("SIGTERM"); + await proc.exited; + const out = await new Response(proc.stdout).text(); + + // A process that died on startup also burns no CPU — assert it really ran the runner and + // was still alive to catch the signal, so the CPU bound below can't pass vacuously. + expect(out).toContain("[runner] nothing to run"); + expect(out).toContain("[runner] SIGTERM"); + + const cpuMs = Number(proc.resourceUsage()?.cpuTime.total ?? 0) / 1000; + expect(cpuMs).toBeLessThan(MAX_CPU_MS); + }, + WINDOW_MS + 15_000, +);