From 6571a26aa99e58acd8ff675551a75d69519f3027 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 25 Jul 2026 14:15:03 +0200 Subject: [PATCH] fix(scripting): park the idle runner instead of pinning a core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `punktfunk-scripting` with nothing to run pinned a full CPU core indefinitely — in exactly the state its own systemd unit documents as inert ("the runner does nothing until you add scripts or install plugins"). A field report on a 7.7 GB laptop had it at 99.9% CPU for two hours straight, `strace` showing a bare `clock_gettime` loop and no other syscall at all, which starved the compositor badly enough to blank the physical display. Nothing in the runner was looping. `Effect.runFork` registers nothing with the event loop — effect's keep-alive timer lives in `Runtime.makeRunMain`, which runner-cli deliberately doesn't use because its shutdown is the bespoke two-signal one — and a bun process whose only pending work is an unresolved promise busy-spins rather than blocking. With units running there is normally a socket or timer to park on; with none there is nothing at all, so the no-op state was the one state that span. Hold one idle handle for the runner's lifetime and drop it when the unit tree ends on its own. Measured on the packaged bun (1.3.14), three seconds idle with empty scripts/plugins dirs: 3098 ms of CPU before, 280 ms after (bun's startup, then nothing). The regression is a property of the process rather than of any function in it, so the test spawns the real CLI and reads the child's CPU time, asserting it also logged and stayed alive — a crashed runner burns no CPU either and must not pass. --- sdk/src/runner-cli.ts | 9 ++++++ sdk/test/runner-cli.test.ts | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 sdk/test/runner-cli.test.ts 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, +);