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:
@@ -149,6 +149,14 @@ if (process.argv.includes("--list")) {
|
|||||||
process.exit(0);
|
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));
|
const fiber = Effect.runFork(runner(options));
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
const shutdown = (signal: string) => {
|
const shutdown = (signal: string) => {
|
||||||
@@ -161,3 +169,4 @@ process.on("SIGINT", () => shutdown("SIGINT"));
|
|||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
await Effect.runPromise(Fiber.await(fiber));
|
await Effect.runPromise(Fiber.await(fiber));
|
||||||
|
clearInterval(keepAlive); // every unit ended on its own — let the process exit
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user