fix(scripting): park the idle runner instead of pinning a core

`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.
This commit is contained in:
2026-07-25 15:13:11 +02:00
parent 8578141d43
commit 6571a26aa9
2 changed files with 65 additions and 0 deletions
+9
View File
@@ -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