From e06ab59652c988c68ebde4e8df638d52de3cc347 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 00:46:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20punktfunk-scripting=20=E2=80=94=20?= =?UTF-8?q?the=20managed=20script/plugin=20runner=20(M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optional supervision layer (RFC §8): one service runs everything in /scripts/ plus installed punktfunk-plugin-* packages (/plugins/node_modules/), as Effect fibers. - Plugins (a definePlugin default export, either main shape) are SUPERVISED: a failure restarts them with capped exponential backoff (jittered, 1s→60s); a clean return completes them. The Effect shape runs under the PunktfunkHost layer; the async-fn shape gets a facade client whose close is scope-guaranteed. - Bare scripts are one-shot: importing them is the run, no restart (export a plugin to be supervised). - Shutdown is STRUCTURAL: SIGINT/SIGTERM interrupt the whole fiber tree, so Effect plugins' scoped finalizers run and clients close before exit — the systemctl-stop story, and the reason the Effect plugin shape exists at all. - The sshd rule applies to unit files (world-writable → refused loudly); cache-busted imports make restarts real; --list for inventory. 6 new bun tests (17 total green): discovery + refusal, both plugin shapes against a mock host, crash→restart with backoff, one-shot semantics, and finalizer-on-interrupt. Live-verified against a real host: a supervised watcher plugin received library.changed through the pinned tunnel, and SIGTERM shut the tree down structurally (exit 0). Deferred to the packaging follow-up (release.yml is in flight in a parallel session): the vendored-Bun deb/rpm/iss packages and the host-log-ring tee (needs a host ingest endpoint); console page rides the other console surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-site/content/docs/automation.md | 9 ++ sdk/README.md | 39 ++++- sdk/package.json | 6 +- sdk/src/runner-cli.ts | 36 +++++ sdk/src/runner.ts | 226 ++++++++++++++++++++++++++ sdk/test/runner.test.ts | 233 +++++++++++++++++++++++++++ 6 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 sdk/src/runner-cli.ts create mode 100644 sdk/src/runner.ts create mode 100644 sdk/test/runner.test.ts diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index 8183e71d..f01ef40f 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -138,6 +138,15 @@ curl -Nk -H "Authorization: Bearer $(cat ~/.config/punktfunk/mgmt-token)" \ `event: dropped` frame first — resync from the REST snapshots (`/status`, `/clients`, …). - `?kinds=` filters server-side: exact kinds or `domain.*` prefixes, comma-separated. +## Scripts, plugins, and the runner + +For anything beyond a `curl` one-liner there is **`@punktfunk/host`** — the TypeScript SDK +(`sdk/` in the repo): typed events with automatic reconnect/resume, the REST surface, and a +plugin convention (`punktfunk-plugin-*`). Its **runner** (`punktfunk-scripting`) supervises a +directory of scripts and installed plugins as one service: crash-restarts with backoff, and a +`systemctl stop` that interrupts plugins structurally so their cleanup runs. See the SDK README +for the five-line quickstart and unit templates. + The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch `pairing.pending`, send yourself a notification, and call `POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at diff --git a/sdk/README.md b/sdk/README.md index 47c6b647..f633fbfe 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -94,7 +94,44 @@ export default definePlugin({ In v1 a plugin is a script you run (see below); the managed runner package is a later step. -## Running as a service +## The runner: `punktfunk-scripting` + +Instead of one unit file per script, run everything under the managed runner — it discovers +your units and supervises them: + +```sh +bun src/runner-cli.ts # runs /scripts/* + installed punktfunk-plugin-* +bun src/runner-cli.ts --list # show what it found +``` + +- **Plugins** (a `definePlugin` default export, from the scripts dir or a + `punktfunk-plugin-*` package installed under `/plugins/`): supervised — a crash + restarts them with capped exponential backoff; a clean return completes them. +- **Bare scripts**: importing them is the run — one-shot, no restart (export a plugin to be + supervised). +- **Shutdown is structural**: SIGINT/SIGTERM interrupt every unit's fiber — Effect plugins' + scoped finalizers run (release the preset, deregister cleanly) and facade clients close + before the process exits. This is what makes `systemctl stop` clean. +- The sshd rule applies: a group/world-writable unit file is refused loudly. + +systemd user unit for the runner (`~/.config/systemd/user/punktfunk-scripting.service`): + +```ini +[Unit] +Description=punktfunk script/plugin runner +After=punktfunk-host.service + +[Service] +ExecStart=/usr/bin/bun /path/to/sdk/src/runner-cli.ts +Restart=on-failure +RestartSec=5 +# SIGTERM (the default KillSignal) triggers the runner's structured shutdown. + +[Install] +WantedBy=default.target +``` + +## Running a single script as a service systemd user unit (`~/.config/systemd/user/punktfunk-myscript.service`): diff --git a/sdk/package.json b/sdk/package.json index 6809bc99..8025af04 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -11,7 +11,8 @@ "scripts": { "gen": "orval --config ./orval.config.ts", "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "runner": "bun src/runner-cli.ts" }, "dependencies": { "effect": "^3.19.0" @@ -23,5 +24,8 @@ }, "optionalDependencies": { "undici": "^7.0.0" + }, + "bin": { + "punktfunk-scripting": "./src/runner-cli.ts" } } diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts new file mode 100644 index 00000000..5333bf86 --- /dev/null +++ b/sdk/src/runner-cli.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env bun +// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under +// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so +// every plugin's finalizers run before exit (the systemd-stop story). +// +// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] +import { Effect, Fiber } from "effect"; +import { discoverUnits, runner } from "./runner.js"; + +const arg = (flag: string): string | undefined => { + const i = process.argv.indexOf(flag); + return i >= 0 ? process.argv[i + 1] : undefined; +}; + +const options = { + scriptsDir: arg("--scripts"), + pluginsDir: arg("--plugins"), +}; + +if (process.argv.includes("--list")) { + for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`); + process.exit(0); +} + +const fiber = Effect.runFork(runner(options)); +let stopping = false; +const shutdown = (signal: string) => { + if (stopping) return process.exit(1); // second signal = get out now + stopping = true; + console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`); + void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0)); +}; +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); + +await Effect.runPromise(Fiber.await(fiber)); diff --git a/sdk/src/runner.ts b/sdk/src/runner.ts new file mode 100644 index 00000000..55b0c76a --- /dev/null +++ b/sdk/src/runner.ts @@ -0,0 +1,226 @@ +// The managed script/plugin runner (RFC §8, M5) — what the `punktfunk-scripting` package runs: +// discover the operator's units, supervise them as Effect fibers, shut down structurally. +// +// Units: +// - **Plugins** — a file whose default export is a [`PluginDef`] (`definePlugin`), from the +// scripts dir or an installed `punktfunk-plugin-*` package. Supervised: a failure restarts +// it with capped exponential backoff; a clean return completes it. The Effect `main` shape +// runs with the `PunktfunkHost` layer provided and is interrupted STRUCTURALLY on shutdown +// (scoped finalizers run — release the preset, deregister cleanly); the async-fn shape gets +// a connected facade client whose close is guaranteed by the same scope. +// - **Bare scripts** — any other `.ts`/`.js` file in the scripts dir: importing it IS the run +// (top-level await). One-shot: completion logs, failure logs — no restart (a bare script's +// background work is invisible to supervision; export a plugin to be supervised). +// +// Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended. +// The same sshd rule as hooks applies: a world-writable unit file is refused loudly. +import { + Cause, + Duration, + Effect, + Schedule, +} from "effect"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { layer as hostLayer, PunktfunkHost } from "./client.js"; +import { type ConnectOptions, configDir } from "./config.js"; +import { connect, type PluginDef } from "./index.js"; + +export interface RunnerOptions { + /** Where loose scripts live. Default `/scripts`. */ + scriptsDir?: string; + /** + * Where plugin packages are installed (`/node_modules/punktfunk-plugin-*`, + * i.e. the operator runs `bun add punktfunk-plugin-x` there). Default `/plugins`. + */ + pluginsDir?: string; + /** Connection overrides handed to every unit's client/layer. */ + connect?: ConnectOptions; + /** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */ + restartBase?: Duration.DurationInput; + /** Line sink. Default: stamped stdout. */ + log?: (line: string) => void; +} + +export interface Unit { + /** Display name: the file stem, or the plugin package name. */ + name: string; + /** Absolute path of the module to import. */ + file: string; +} + +const defaultLog = (line: string) => + console.log(`${new Date().toISOString()} ${line}`); + +/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */ +const fileIsSafe = (file: string, log: (l: string) => void): boolean => { + if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up + try { + const mode = fs.statSync(file).mode & 0o022; + if (mode !== 0) { + log( + `[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`, + ); + return false; + } + } catch { + return false; + } + return true; +}; + +const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]); + +/** Enumerate the operator's units: loose scripts plus installed plugin packages. */ +export const discoverUnits = ( + options: RunnerOptions = {}, + log: (l: string) => void = options.log ?? defaultLog, +): Unit[] => { + const units: Unit[] = []; + const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts"); + const pluginsDir = options.pluginsDir ?? path.join(configDir(), "plugins"); + try { + for (const entry of fs.readdirSync(scriptsDir).sort()) { + const file = path.join(scriptsDir, entry); + if (!SCRIPT_EXTENSIONS.has(path.extname(entry))) continue; + if (!fs.statSync(file).isFile()) continue; + if (!fileIsSafe(file, log)) continue; + units.push({ name: path.basename(entry, path.extname(entry)), file }); + } + } catch { + // no scripts dir — fine + } + const modules = path.join(pluginsDir, "node_modules"); + try { + for (const pkg of fs.readdirSync(modules).sort()) { + if (!pkg.startsWith("punktfunk-plugin-")) continue; + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"), + ) as { main?: string; module?: string }; + const rel = manifest.module ?? manifest.main ?? "index.js"; + const file = path.join(modules, pkg, rel); + if (!fileIsSafe(file, log)) continue; + units.push({ name: pkg, file }); + } catch (e) { + log(`[runner] skipping ${pkg}: unreadable package.json (${e})`); + } + } + } catch { + // no plugins dir — fine + } + return units; +}; + +const isPluginDef = (v: unknown): v is PluginDef => + typeof v === "object" && + v !== null && + typeof (v as PluginDef).name === "string" && + (v as PluginDef).main !== undefined; + +/** One attempt at a unit: import (cache-busted per attempt) and run whatever it exports. */ +const attemptUnit = ( + unit: Unit, + attempt: number, + options: RunnerOptions, + log: (l: string) => void, +): Effect.Effect<"plugin" | "script", unknown> => + Effect.gen(function* () { + const mod = (yield* Effect.tryPromise( + () => import(`${pathToFileURL(unit.file).href}?attempt=${attempt}`), + )) as { default?: unknown }; + if (!isPluginDef(mod.default)) { + return "script" as const; // the import WAS the run (top-level await) + } + const def = mod.default; + if (Effect.isEffect(def.main)) { + // The well-behaved shape: interruption reaches it structurally, its scoped + // finalizers run on shutdown. + yield* (def.main as Effect.Effect).pipe( + Effect.provide(hostLayer(options.connect)), + ); + } else { + // The simple shape: a facade client whose close is guaranteed by the scope — + // on completion, failure, OR interruption (shutdown). + const main = def.main as (pf: unknown) => Promise | unknown; + yield* Effect.scoped( + Effect.gen(function* () { + const pf = yield* Effect.acquireRelease( + Effect.tryPromise(() => connect(options.connect)), + (client) => Effect.sync(() => client.close()), + ); + yield* Effect.tryPromise(async () => await main(pf)); + }), + ); + } + return "plugin" as const; + }); + +/** + * A unit under supervision: plugins restart on failure (capped exponential backoff, jittered); + * a clean completion ends the unit; bare scripts are one-shot either way. Never fails the + * runner — every outcome is logged. + */ +export const superviseUnit = ( + unit: Unit, + options: RunnerOptions = {}, +): Effect.Effect => { + const log = options.log ?? defaultLog; + const restart = Schedule.exponential(options.restartBase ?? "1 second").pipe( + Schedule.union(Schedule.spaced("60 seconds")), // cap + Schedule.jittered, + ); + let attempt = 0; + const once = Effect.suspend(() => { + attempt += 1; + if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`); + return attemptUnit(unit, attempt, options, log); + }); + return once.pipe( + Effect.tap((kind) => + Effect.sync(() => + log( + kind === "script" + ? `[${unit.name}] script completed` + : `[${unit.name}] plugin completed`, + ), + ), + ), + Effect.tapErrorCause((cause) => + Effect.sync(() => + log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`), + ), + ), + Effect.retry(restart), + Effect.catchAllCause((cause) => + // A retry schedule that gives up (it doesn't, but stay total) — log and end. + Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)), + ), + Effect.asVoid, + ); +}; + +/** + * The runner: discover units, supervise each as a fiber, run until interrupted — at which + * point every unit is interrupted STRUCTURALLY (scoped finalizers run: facade clients close, + * Effect plugins release what they acquired). + */ +export const runner = (options: RunnerOptions = {}): Effect.Effect => { + const log = options.log ?? defaultLog; + return Effect.scoped( + Effect.gen(function* () { + const units = discoverUnits(options, log); + if (units.length === 0) { + log( + "[runner] nothing to run — add scripts to the scripts dir or install punktfunk-plugin-* packages", + ); + } + for (const unit of units) { + log(`[runner] starting ${unit.name} (${unit.file})`); + yield* Effect.forkScoped(superviseUnit(unit, options)); + } + yield* Effect.never; // interruption (shutdown) collapses the scope → all units + }), + ); +}; diff --git a/sdk/test/runner.test.ts b/sdk/test/runner.test.ts new file mode 100644 index 00000000..3b9bad82 --- /dev/null +++ b/sdk/test/runner.test.ts @@ -0,0 +1,233 @@ +// The runner's contract: discovery (scripts + plugin packages + the sshd refusal), both plugin +// shapes running against a mock host, crash → supervised restart, one-shot scripts, and the +// M5 heart — structured interruption running scoped finalizers on shutdown. +import { afterAll, describe, expect, test } from "bun:test"; +import { Effect, Fiber } from "effect"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { discoverUnits, runner, superviseUnit } from "../src/runner.js"; + +const TOKEN = "runner-token"; +// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK. +const ROOT = path.join(import.meta.dir, "..", `.runner-fixtures-${process.pid}`); +fs.mkdirSync(ROOT, { recursive: true }); +afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +const mkdirs = (name: string) => { + const dir = path.join(ROOT, name); + fs.mkdirSync(path.join(dir, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(dir, "plugins", "node_modules"), { recursive: true }); + return { + scriptsDir: path.join(dir, "scripts"), + pluginsDir: path.join(dir, "plugins"), + dir, + }; +}; + +const write = (file: string, content: string) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + fs.chmodSync(file, 0o644); +}; + +const mockHost = () => + Bun.serve({ + port: 0, + fetch(req) { + if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) + return Response.json({ error: "no" }, { status: 401 }); + const p = new URL(req.url).pathname; + if (p === "/api/v1/host") return Response.json({ hostname: "mock" }); + if (p === "/api/v1/status") return Response.json({ ok: true }); + return Response.json({ error: "nf" }, { status: 404 }); + }, + }); + +const waitFor = async (predicate: () => boolean, ms = 5000) => { + const deadline = Date.now() + ms; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("condition never became true"); + await new Promise((r) => setTimeout(r, 25)); + } +}; + +describe("discovery", () => { + test("finds scripts + plugin packages, refuses world-writable files", () => { + const d = mkdirs("discover"); + write(path.join(d.scriptsDir, "b-script.ts"), "export {};"); + write(path.join(d.scriptsDir, "a-script.ts"), "export {};"); + write(path.join(d.scriptsDir, "notes.txt"), "not code"); + const evil = path.join(d.scriptsDir, "evil.ts"); + write(evil, "export {};"); + fs.chmodSync(evil, 0o777); + write( + path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "package.json"), + JSON.stringify({ name: "punktfunk-plugin-x", main: "index.js" }), + ); + write( + path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "index.js"), + "export default { name: 'x', main: async () => {} };", + ); + write( + path.join(d.pluginsDir, "node_modules", "unrelated-pkg", "package.json"), + JSON.stringify({ name: "unrelated-pkg" }), + ); + + const logs: string[] = []; + const units = discoverUnits(d, (l) => logs.push(l)); + expect(units.map((u) => u.name)).toEqual([ + "a-script", + "b-script", + "punktfunk-plugin-x", + ]); + expect(logs.join("\n")).toContain("REFUSING"); + expect(logs.join("\n")).toContain("evil.ts"); + }); +}); + +describe("supervision", () => { + test("async-fn plugin runs with a facade client; clean return completes", async () => { + const server = mockHost(); + const d = mkdirs("fn-plugin"); + const out = path.join(d.dir, "out.txt"); + write( + path.join(d.scriptsDir, "fn.ts"), + `export default { name: "fn", main: async (pf) => { + const host = await pf.request("GET", "/host"); + require("node:fs").writeFileSync(${JSON.stringify(out)}, host.hostname); + }};`, + ); + const logs: string[] = []; + try { + const fiber = Effect.runFork( + runner({ + ...d, + connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN }, + log: (l) => logs.push(l), + }), + ); + await waitFor(() => fs.existsSync(out)); + expect(fs.readFileSync(out, "utf8")).toBe("mock"); + await waitFor(() => logs.some((l) => l.includes("[fn] plugin completed"))); + await Effect.runPromise(Fiber.interrupt(fiber)); + } finally { + server.stop(true); + } + }); + + test("a crashing plugin is restarted with backoff", async () => { + const server = mockHost(); + const d = mkdirs("crashy"); + const counter = path.join(d.dir, "count.txt"); + write( + path.join(d.scriptsDir, "crashy.ts"), + `import * as fs from "node:fs"; + export default { name: "crashy", main: async () => { + const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0; + fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1)); + throw new Error("boom " + n); + }};`, + ); + const logs: string[] = []; + try { + const fiber = Effect.runFork( + runner({ + ...d, + connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN }, + restartBase: "20 millis", + log: (l) => logs.push(l), + }), + ); + await waitFor(() => { + try { + return Number(fs.readFileSync(counter, "utf8")) >= 3; + } catch { + return false; + } + }); + expect(logs.some((l) => l.includes("[crashy] failed: "))).toBe(true); + expect(logs.some((l) => l.includes("restarting (attempt 2)"))).toBe(true); + await Effect.runPromise(Fiber.interrupt(fiber)); + } finally { + server.stop(true); + } + }); + + test("a bare script is one-shot: runs on import, never restarts", async () => { + const d = mkdirs("bare"); + const counter = path.join(d.dir, "ran.txt"); + write( + path.join(d.scriptsDir, "once.ts"), + `import * as fs from "node:fs"; + const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0; + fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));`, + ); + const logs: string[] = []; + const fiber = Effect.runFork( + runner({ ...d, restartBase: "20 millis", log: (l) => logs.push(l) }), + ); + await waitFor(() => logs.some((l) => l.includes("[once] script completed"))); + await new Promise((r) => setTimeout(r, 200)); // would have restarted by now + expect(fs.readFileSync(counter, "utf8")).toBe("1"); + await Effect.runPromise(Fiber.interrupt(fiber)); + }); + + test("shutdown interrupts an Effect plugin STRUCTURALLY — its finalizer runs", async () => { + const server = mockHost(); + const d = mkdirs("finalizer"); + const acquired = path.join(d.dir, "acquired.txt"); + const released = path.join(d.dir, "released.txt"); + write( + path.join(d.scriptsDir, "holder.ts"), + `import { Effect } from "effect"; + import * as fs from "node:fs"; + export default { name: "holder", main: Effect.scoped(Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => fs.writeFileSync(${JSON.stringify(acquired)}, "1")), + () => Effect.sync(() => fs.writeFileSync(${JSON.stringify(released)}, "1")), + ); + yield* Effect.never; // hold the resource for the plugin's lifetime + })) };`, + ); + try { + const fiber = Effect.runFork( + runner({ + ...d, + connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN }, + log: () => {}, + }), + ); + await waitFor(() => fs.existsSync(acquired)); + expect(fs.existsSync(released)).toBe(false); + await Effect.runPromise(Fiber.interrupt(fiber)); // the SIGTERM path + await waitFor(() => fs.existsSync(released)); + } finally { + server.stop(true); + } + }); + + test("supervised unit ends cleanly when its plugin completes (no spin)", async () => { + const server = mockHost(); + const d = mkdirs("done"); + write( + path.join(d.scriptsDir, "done.ts"), + `export default { name: "done", main: async () => {} };`, + ); + const logs: string[] = []; + try { + await Effect.runPromise( + superviseUnit( + { name: "done", file: path.join(d.scriptsDir, "done.ts") }, + { + connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN }, + log: (l) => logs.push(l), + restartBase: "10 millis", + }, + ), + ); + expect(logs.filter((l) => l.includes("restarting")).length).toBe(0); + } finally { + server.stop(true); + } + }); +});