From 54e680f284fa95f6bb55d01b692bba932fddad6c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:13:49 +0200 Subject: [PATCH 01/26] GPU tenancy gate: hysteresis gate, probe child and tests One owner for "may heavy GPU work start": numbers-only samples (full-screen state, sustained 3D use, host free VRAM) in, { inUse, since, checkedAt, probeOk } out; fail closed at start and when the probe goes stale. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 + scripts/gpu-tenancy-probe.ps1 | 20 +++ scripts/gpu-tenancy.mjs | 143 +++++++++++++++++ scripts/test-gpu-gate-http.mjs | 65 ++++++++ scripts/test-gpu-tenancy.mjs | 271 +++++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 scripts/gpu-tenancy-probe.ps1 create mode 100644 scripts/gpu-tenancy.mjs create mode 100644 scripts/test-gpu-gate-http.mjs create mode 100644 scripts/test-gpu-tenancy.mjs diff --git a/package.json b/package.json index 25f6a94..4164e53 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "start": "node scripts/vili-daemon.mjs", "health": "node scripts/vili-daemon.mjs --health", + "test": "node --test scripts/test-gpu-tenancy.mjs", + "test:http": "node --test scripts/test-gpu-gate-http.mjs", "smoke": "node scripts/smoke-vili.mjs", "provider": "node scripts/vili-daemon.mjs --print-provider-advertisement" }, diff --git a/scripts/gpu-tenancy-probe.ps1 b/scripts/gpu-tenancy-probe.ps1 new file mode 100644 index 0000000..c80bf2b --- /dev/null +++ b/scripts/gpu-tenancy-probe.ps1 @@ -0,0 +1,20 @@ +# One sample line per interval: {"quns":n,"freeMiB":n,"util3d":n}. Numbers only; no process identity is read. +param([int]$ParentPid, [int]$IntervalMs = 15000) +$ErrorActionPreference = 'Stop' +Add-Type -Namespace Vili -Name Shell -MemberDefinition '[DllImport("shell32.dll")] public static extern int SHQueryUserNotificationState(out int state);' +while ($true) { + $clock = [Diagnostics.Stopwatch]::StartNew() + if (-not (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue)) { exit } + try { + $quns = 0 + if ([Vili.Shell]::SHQueryUserNotificationState([ref]$quns) -ne 0) { throw 'quns' } + $free = nvidia-smi.exe --query-gpu=memory.free --format=csv,noheader,nounits | Select-Object -First 1 + if ($LASTEXITCODE -ne 0) { throw 'smi' } + $sum = (Get-Counter '\GPU Engine(*engtype_3D)\Utilization Percentage' -SampleInterval 1 -MaxSamples 1).CounterSamples | Measure-Object -Property CookedValue -Sum + $line = [ordered]@{ quns = [int]$quns; freeMiB = [int]$free; util3d = [math]::Min(100, [math]::Round([double]$sum.Sum, 1)) } | ConvertTo-Json -Compress + [Console]::Out.WriteLine($line) + [Console]::Out.Flush() + } catch { } + $rest = $IntervalMs - $clock.ElapsedMilliseconds + if ($rest -gt 0) { Start-Sleep -Milliseconds $rest } +} diff --git a/scripts/gpu-tenancy.mjs b/scripts/gpu-tenancy.mjs new file mode 100644 index 0000000..1009a66 --- /dev/null +++ b/scripts/gpu-tenancy.mjs @@ -0,0 +1,143 @@ +// GPU tenancy gate: the one owner of "may heavy GPU work start now", from the host's view. +// It reads numbers only (full-screen state, 3D-engine use, host free VRAM), keeps a yes/no and a +// timestamp, and never learns which program is running. It refuses new work; it never stops work. + +export const RETRY_AFTER_SECONDS = 60; +const QUNS_BUSY = 2; +const QUNS_RUNNING_D3D_FULL_SCREEN = 3; + +export function createGpuTenancy({ + now, + sampleIntervalMs = 15000, + clearHoldMs = 180000, + minFreeMiB = 10240, + util3dBusyPct = 20, + staleAfterMs = 3 * sampleIntervalMs, +}) { + const startedAt = now(); + let inUse = true; + let since = startedAt; + let checkedAt = null; + let lastSeenAt = startedAt; + let clearSince = null; + let previousUtil3d = null; + + const staleSince = () => (now() - lastSeenAt > staleAfterMs ? lastSeenAt + staleAfterMs : null); + + function observe(sample) { + const t = now(); + const staleAt = staleSince(); + if (staleAt !== null) { + if (!inUse) { + inUse = true; + since = staleAt; + } + clearSince = null; + previousUtil3d = null; + } + const sustained3d = sample.util3d > util3dBusyPct && previousUtil3d !== null && previousUtil3d > util3dBusyPct; + const busy = sample.quns === QUNS_BUSY + || sample.quns === QUNS_RUNNING_D3D_FULL_SCREEN + || sample.freeMiB < minFreeMiB + || sustained3d; + previousUtil3d = sample.util3d; + lastSeenAt = t; + checkedAt = t; + if (busy) { + clearSince = null; + if (!inUse) { + inUse = true; + since = t; + } + return; + } + if (clearSince === null) clearSince = t; + if (inUse && t - clearSince >= clearHoldMs) { + inUse = false; + since = t; + } + } + + function status() { + const staleAt = staleSince(); + if (staleAt !== null) { + return { inUse: true, since: inUse ? since : staleAt, checkedAt, probeOk: false }; + } + return { inUse, since, checkedAt, probeOk: true }; + } + + return { observe, status }; +} + +// A probe line is JSON; only the three finite numbers survive, so nothing else can reach the gate. +export function parseSample(line) { + let value; + try { + value = JSON.parse(line); + } catch { + return null; + } + if (value === null || typeof value !== "object") return null; + const { quns, freeMiB, util3d } = value; + for (const field of [quns, freeMiB, util3d]) { + if (typeof field !== "number" || !Number.isFinite(field)) return null; + } + return { quns, freeMiB, util3d }; +} + +// Checks the gate once. A busy gate refuses without calling the job; a free gate runs the job to +// its end and returns its result: the gate never holds, pauses or cancels a job. +export async function admitThen(gate, job) { + const gpu = gate.status(); + if (gpu.inUse) return { ok: false, code: "gpu-busy", source: "admission", gpu }; + return job(); +} + +// The encoder child's placement refusal reaches the daemon as a typed code on the worker's error body; +// it is never read out of error text. Returns the typed 503 body, or null for any other outcome. +export function placementRefusal(workerBody, gpu, jobId) { + if (workerBody?.code !== "gpu-busy") return null; + return { ok: false, code: "gpu-busy", source: "placement", gpu, jobId }; +} + +// One long-lived hidden PowerShell child feeds samples. It exits with the daemon (the script watches +// the parent pid); if it exits on its own it is respawned at most once per interval. +export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid = process.pid }) { + let stopped = false; + let lastSpawnAt = 0; + + function launch() { + if (stopped) return; + lastSpawnAt = Date.now(); + const child = spawn("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", scriptPath, + "-ParentPid", String(parentPid), + "-IntervalMs", String(intervalMs), + ], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }); + let pending = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const line of lines) { + const sample = parseSample(line.trim()); + if (sample) onSample(sample); + } + }); + let respawned = false; + const respawn = () => { + if (respawned) return; + respawned = true; + setTimeout(launch, Math.max(0, lastSpawnAt + intervalMs - Date.now())).unref(); + }; + child.on("exit", respawn); + child.on("error", respawn); + } + + launch(); + return { stop: () => { stopped = true; } }; +} diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs new file mode 100644 index 0000000..325b1f5 --- /dev/null +++ b/scripts/test-gpu-gate-http.mjs @@ -0,0 +1,65 @@ +// Boots the real daemon and observes the gate where it is decided: the HTTP edge. A fresh daemon is +// in use until a clear hold passes, so a new job is refused whatever the host is doing. Needs the +// daemon's own dependencies (ws, cultcache-ts), so it runs on a machine that has them. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const daemonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "vili-daemon.mjs"); +const port = 18000 + Math.floor(Math.random() * 1000); + +async function bootDaemon(stateRoot) { + const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(port), "--state-root", stateRoot], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + const deadline = Date.now() + 30000; + while (!output.includes("Vili listening")) { + if (child.exitCode !== null || Date.now() > deadline) throw new Error(`daemon did not start: ${output.slice(-500)}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return child; +} + +const post = (body) => fetch(`http://127.0.0.1:${port}/motion/generate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +test("a new job is refused 503 gpu-busy with retry-after, creating nothing; validation stays 400", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-")); + const daemon = await bootDaemon(stateRoot); + try { + const invalid = await post({}); + assert.equal(invalid.status, 400, "request-shape errors come ahead of the gate"); + + const refused = await post({ prompt: "a person waves hello, never seen before" }); + assert.equal(refused.status, 503); + assert.equal(refused.headers.get("retry-after"), "60"); + const body = await refused.json(); + assert.equal(body.ok, false); + assert.equal(body.code, "gpu-busy"); + assert.equal(body.source, "admission"); + assert.equal(body.gpu.inUse, true); + assert.deepEqual(Object.keys(body.gpu).sort(), ["checkedAt", "inUse", "probeOk", "since"]); + assert.equal(body.jobId, undefined); + + assert.deepEqual(await readdir(path.join(stateRoot, "jobs")), [], "no job file"); + assert.equal((await readdir(stateRoot)).includes("artifacts"), false, "no artifact directory"); + + const state = await (await fetch(`http://127.0.0.1:${port}/operator-state`)).json(); + assert.deepEqual(Object.keys(state.gpu).sort(), ["checkedAt", "inUse", "probeOk", "since"]); + assert.equal(state.gpu.inUse, true); + } finally { + daemon.kill(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs new file mode 100644 index 0000000..6e56f83 --- /dev/null +++ b/scripts/test-gpu-tenancy.mjs @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { admitThen, createGpuTenancy, parseSample, placementRefusal, startProbe } from "./gpu-tenancy.mjs"; + +const SEC = 1000; +const CLEAR = { quns: 1, freeMiB: 14000, util3d: 1 }; + +function rig(options = {}) { + let t = 1_000_000; + const gate = createGpuTenancy({ now: () => t, ...options }); + return { + gate, + t0: t, + at: () => t, + advance: (ms) => { t += ms; }, + feed(sample, afterMs = 15 * SEC) { + t += afterMs; + gate.observe(sample); + }, + }; +} + +function freeRig(options) { + const r = rig(options); + r.feed(CLEAR); + r.advance(180 * SEC); + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, false, "precondition: gate is free"); + return r; +} + +test("hysteresis: busy holds for the clear window", () => { + const r = rig(); + r.feed(CLEAR); + r.feed({ ...CLEAR, quns: 3 }); + const busyAt = r.at(); + assert.deepEqual(r.gate.status(), { inUse: true, since: busyAt, checkedAt: busyAt, probeOk: true }); + for (let elapsed = 15; elapsed < 180; elapsed += 15) { + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, true, `still held ${elapsed} s into the hold`); + assert.equal(r.gate.status().since, busyAt); + } + // a busy sample late in the hold restarts it + r.feed({ ...CLEAR, quns: 2 }); + const restartAt = r.at(); + for (let elapsed = 15; elapsed < 180; elapsed += 15) { + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, true, `restarted hold, ${elapsed} s`); + } + r.feed(CLEAR); + assert.equal(r.at() - restartAt, 180 * SEC); + const freedAt = r.at(); + assert.deepEqual(r.gate.status(), { inUse: false, since: freedAt, checkedAt: freedAt, probeOk: true }); + r.feed(CLEAR); + assert.equal(r.gate.status().since, freedAt, "since moves only when the state turns"); + r.feed({ ...CLEAR, freeMiB: 100 }); + assert.deepEqual(r.gate.status(), { inUse: true, since: r.at(), checkedAt: r.at(), probeOk: true }); +}); + +test("hysteresis: a busy sample just before the window ends restarts it", () => { + const r = rig(); + r.feed(CLEAR); + for (let i = 0; i < 11; i += 1) r.feed(CLEAR); + assert.equal(r.gate.status().inUse, true, "165 s clear is not enough"); + r.feed({ ...CLEAR, quns: 3 }); + r.feed(CLEAR); + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, true); +}); + +test("sustained 3D, immediate full-screen and VRAM", () => { + const spike = freeRig(); + spike.feed({ ...CLEAR, util3d: 80 }); + spike.feed(CLEAR); + assert.equal(spike.gate.status().inUse, false, "one 3D spike between clear samples"); + + const sustained = freeRig(); + sustained.feed({ ...CLEAR, util3d: 80 }); + assert.equal(sustained.gate.status().inUse, false, "first high sample alone"); + sustained.feed({ ...CLEAR, util3d: 80 }); + assert.equal(sustained.gate.status().inUse, true, "two consecutive high samples"); + + const edge = freeRig(); + edge.feed({ ...CLEAR, util3d: 20 }); + edge.feed({ ...CLEAR, util3d: 20 }); + assert.equal(edge.gate.status().inUse, false, "exactly the threshold is not above it"); + + for (const [name, sample] of [ + ["QUNS_BUSY", { ...CLEAR, quns: 2 }], + ["QUNS_RUNNING_D3D_FULL_SCREEN", { ...CLEAR, quns: 3 }], + ["low free VRAM", { ...CLEAR, freeMiB: 10239 }], + ]) { + const r = freeRig(); + r.feed(sample); + assert.equal(r.gate.status().inUse, true, name); + } + + const enough = freeRig(); + enough.feed({ ...CLEAR, freeMiB: 10240 }); + assert.equal(enough.gate.status().inUse, false, "free VRAM equal to the minimum is enough"); + + const otherStates = freeRig(); + for (const quns of [1, 4, 5, 6, 7]) otherStates.feed({ ...CLEAR, quns }); + assert.equal(otherStates.gate.status().inUse, false, "other notification states are not household use"); +}); + +test("thresholds from the options are honored", () => { + const r = rig({ minFreeMiB: 500, util3dBusyPct: 90, clearHoldMs: 30 * SEC }); + r.feed({ ...CLEAR, freeMiB: 600, util3d: 85 }); + r.feed({ ...CLEAR, freeMiB: 600, util3d: 85 }); + r.feed({ ...CLEAR, freeMiB: 600, util3d: 85 }); + assert.equal(r.gate.status().inUse, false); + r.feed({ ...CLEAR, freeMiB: 400 }); + assert.equal(r.gate.status().inUse, true); +}); + +test("start and stale fail closed", () => { + const r = rig(); + assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: true }); + r.feed(CLEAR); + r.advance(180 * SEC); + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, false); + const lastSample = r.at(); + + r.advance(45 * SEC); + assert.equal(r.gate.status().probeOk, true, "exactly 3 intervals is not stale yet"); + r.advance(1); + const stale = r.gate.status(); + assert.equal(stale.inUse, true); + assert.equal(stale.probeOk, false); + assert.equal(stale.since, lastSample + 45 * SEC); + assert.equal(stale.checkedAt, lastSample); + + r.feed(CLEAR, 0); + assert.equal(r.gate.status().inUse, true, "samples resumed but the hold has not passed"); + assert.equal(r.gate.status().probeOk, true); + for (let i = 0; i < 11; i += 1) r.feed(CLEAR); + assert.equal(r.gate.status().inUse, true); + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, false); +}); + +test("a gate that never hears from its probe stays in use and not ok", () => { + const r = rig(); + r.advance(46 * SEC); + assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: false }); +}); + +test("defer, not kill", async () => { + const r = freeRig(); + let release; + let started = 0; + const running = admitThen(r.gate, () => { + started += 1; + return new Promise((resolve) => { release = () => resolve({ ok: true, value: "finished" }); }); + }); + assert.equal(started, 1); + r.feed({ ...CLEAR, quns: 3 }); + assert.equal(r.gate.status().inUse, true, "the gate went busy while the job runs"); + + let secondCalls = 0; + const second = await admitThen(r.gate, async () => { secondCalls += 1; }); + assert.equal(secondCalls, 0, "a second job is refused without being called"); + assert.equal(second.code, "gpu-busy"); + + release(); + assert.deepEqual(await running, { ok: true, value: "finished" }); +}); + +test("a job that fails is not swallowed by the gate", async () => { + const r = freeRig(); + await assert.rejects(admitThen(r.gate, async () => { throw new Error("job failed"); }), /job failed/); +}); + +test("typed busy result", async () => { + const r = rig(); + r.feed({ ...CLEAR, quns: 3 }); + const since = r.gate.status().since; + r.feed({ ...CLEAR, quns: 3 }); + r.feed({ ...CLEAR, quns: 3 }); + let calls = 0; + const refusal = await admitThen(r.gate, async () => { calls += 1; }); + assert.deepEqual(refusal, { + ok: false, + code: "gpu-busy", + source: "admission", + gpu: { inUse: true, since, checkedAt: r.at(), probeOk: true }, + }); + assert.equal(calls, 0); +}); + +test("placement refusal is keyed on the typed code, never on text", () => { + const gpu = { inUse: false, since: 1, checkedAt: 2, probeOk: true }; + assert.deepEqual(placementRefusal({ ok: false, code: "gpu-busy", error: "x" }, gpu, "vili-1"), { + ok: false, code: "gpu-busy", source: "placement", gpu, jobId: "vili-1", + }); + assert.equal(placementRefusal({ ok: false, error: "gpu-busy: 0.50 GiB of VRAM free" }, gpu, "vili-1"), null); + assert.equal(placementRefusal({ ok: false, code: null }, gpu, "vili-1"), null); + assert.equal(placementRefusal(null, gpu, "vili-1"), null); +}); + +test("no program name canary", () => { + const canary = "canary-vili-7f3a"; + const line = JSON.stringify({ quns: 3, freeMiB: 2048, util3d: 55.5, name: canary, process: canary, instance: canary }); + const sample = parseSample(line); + assert.deepEqual(sample, { quns: 3, freeMiB: 2048, util3d: 55.5 }); + assert.deepEqual(Object.keys(sample).sort(), ["freeMiB", "quns", "util3d"]); + const r = rig(); + r.gate.observe(sample); + assert.equal(JSON.stringify(r.gate.status()).includes(canary), false); +}); + +test("parseSample drops anything that is not three finite numbers", () => { + assert.equal(parseSample("not json"), null); + assert.equal(parseSample("null"), null); + assert.equal(parseSample("[1,2,3]"), null); + assert.equal(parseSample(JSON.stringify({ quns: 1, freeMiB: 1 })), null); + assert.equal(parseSample(JSON.stringify({ quns: "1", freeMiB: 1, util3d: 1 })), null); + assert.equal(parseSample('{"quns":1,"freeMiB":1,"util3d":1e999}'), null); + assert.equal(parseSample(""), null); +}); + +function fakeSpawn() { + const spawned = []; + const spawn = (command, args, options) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stdout.setEncoding = () => {}; + spawned.push({ command, args, options, child }); + return child; + }; + return { spawn, spawned }; +} + +test("probe: one hidden child, lines become samples, junk is dropped", () => { + const { spawn, spawned } = fakeSpawn(); + const samples = []; + const probe = startProbe({ spawn, scriptPath: "gpu-tenancy-probe.ps1", intervalMs: 15000, onSample: (s) => samples.push(s), parentPid: 4242 }); + assert.equal(spawned.length, 1); + const { command, args, options, child } = spawned[0]; + assert.equal(command, "powershell.exe"); + assert.equal(options.windowsHide, true); + assert.deepEqual(options.stdio, ["ignore", "pipe", "ignore"]); + assert.deepEqual(args.slice(args.indexOf("-ParentPid")), ["-ParentPid", "4242", "-IntervalMs", "15000"]); + assert.ok(args.includes("-File") && args.includes("gpu-tenancy-probe.ps1")); + + child.stdout.emit("data", '{"quns":1,"freeMiB":14000,"util3d":2}\n{"quns":2,"free'); + child.stdout.emit("data", 'MiB":9000,"util3d":3}\r\ngarbage\n'); + assert.deepEqual(samples, [ + { quns: 1, freeMiB: 14000, util3d: 2 }, + { quns: 2, freeMiB: 9000, util3d: 3 }, + ]); + probe.stop(); +}); + +test("probe: respawns at most once per interval after the child exits", async () => { + const { spawn, spawned } = fakeSpawn(); + const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 80, onSample: () => {} }); + spawned[0].child.emit("exit", 1); + spawned[0].child.emit("error", new Error("again")); + spawned[0].child.emit("exit", 1); + assert.equal(spawned.length, 1, "no immediate respawn"); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(spawned.length, 2, "one respawn for one death, however many events"); + probe.stop(); + spawned[1].child.emit("exit", 1); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(spawned.length, 2, "a stopped probe does not respawn"); +}); From 38491f8917d0ccb86a6f58781391fc0431500350 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:13:49 +0200 Subject: [PATCH 02/26] Daemon: admit /motion/generate through the tenancy gate, typed 503 gpu-busy Refuse a new job with 503 + retry-after 60 while the GPU is in use (source admission); a placement refusal from the encoder child also answers 503 (source placement). Publish the gate as operator_state.gpu. A running job is never touched. Co-Authored-By: Claude Opus 5.5 --- scripts/vili-daemon.mjs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index e75e0ac..34ebbcf 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -4,8 +4,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { WebSocketServer } from "ws"; +import { RETRY_AFTER_SECONDS, admitThen, createGpuTenancy, placementRefusal, startProbe } from "./gpu-tenancy.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -59,6 +60,7 @@ const operatorStateDefinition = defineDocumentType({ { slot: 1, memberName: "providerId", typeName: "string" }, { slot: 2, memberName: "updatedAt", typeName: "string" }, { slot: 3, memberName: "backend", typeName: "object" }, + { slot: 4, memberName: "gpu", typeName: "object" }, ], }); const eveSurfaceDefinition = defineDocumentType({ @@ -135,6 +137,15 @@ function parseArgs(argv) { const args = parseArgs(process.argv.slice(2)); const startedAt = new Date().toISOString(); +const envNumber = (name) => (process.env[name] === undefined ? undefined : Number(process.env[name])); +const sampleIntervalMs = envNumber("VILI_GPU_SAMPLE_MS"); +const gpuTenancy = createGpuTenancy({ + now: Date.now, + sampleIntervalMs, + clearHoldMs: envNumber("VILI_GPU_CLEAR_HOLD_MS"), + minFreeMiB: envNumber("VILI_GPU_MIN_FREE_MIB"), + util3dBusyPct: envNumber("VILI_GPU_UTIL3D_BUSY_PCT"), +}); let backendStatus = { checkedAt: null, dockerImage: "unknown", @@ -193,9 +204,10 @@ async function wslJson(script, options = {}) { } } -function json(response, statusCode, body) { +function json(response, statusCode, body, headers = {}) { const payload = JSON.stringify(body, null, 2); response.writeHead(statusCode, { + ...headers, "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "*", @@ -252,6 +264,7 @@ function operatorState() { startedAt, updatedAt: new Date().toISOString(), backend: backendStatus, + gpu: gpuTenancy.status(), authority: { owns: [ "animation job intake", @@ -526,6 +539,13 @@ async function generateMotion(request, response) { return; } + const admitted = await admitThen(gpuTenancy, () => runMotionJob(body, prompt, response)); + if (admitted?.code === "gpu-busy") { + json(response, 503, admitted, { "retry-after": String(RETRY_AFTER_SECONDS) }); + } +} + +async function runMotionJob(body, prompt, response) { const jobId = `vili-${Date.now()}`; const artifactDir = path.join(args.stateRoot, "artifacts", jobId); await mkdir(artifactDir, { recursive: true }); @@ -650,7 +670,9 @@ async function generateMotion(request, response) { await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true }); await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)); }); - json(response, result.ok ? 200 : 502, job); + const placement = result.ok ? null : placementRefusal(result.body, gpuTenancy.status(), jobId); + if (placement) json(response, 503, placement, { "retry-after": String(RETRY_AFTER_SECONDS) }); + else json(response, result.ok ? 200 : 502, job); } async function handleRequest(request, response) { @@ -748,4 +770,13 @@ if (args.health) { }); setInterval(() => persistSurfaces().catch((error) => console.error(error)), 5000); + + if (process.platform === "win32") { + startProbe({ + spawn, + scriptPath: path.join(__dirname, "gpu-tenancy-probe.ps1"), + intervalMs: sampleIntervalMs ?? 15000, + onSample: (sample) => gpuTenancy.observe(sample), + }); + } } From 880f1c2dcfb5e6cfcfcd0cb61c92c49cf4fa8cd6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:13:50 +0200 Subject: [PATCH 03/26] Worker: error body carries the typed code (closes gpu-busy-untyped-http) worker_error_body is the worker's one error wire shape; GpuBusy.code reaches the daemon as a field. GpuBusy is documented as placement only. Co-Authored-By: Claude Opus 5.5 --- scripts/kimodo-resident-worker.py | 7 +------ scripts/test_text_encoding.py | 6 ++++++ scripts/text_encoding.py | 12 +++++++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/kimodo-resident-worker.py b/scripts/kimodo-resident-worker.py index 846ace9..d699a2c 100644 --- a/scripts/kimodo-resident-worker.py +++ b/scripts/kimodo-resident-worker.py @@ -4,7 +4,6 @@ import json import os import shutil import time -import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Lock from types import SimpleNamespace @@ -394,11 +393,7 @@ class Handler(BaseHTTPRequestHandler): payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") write_json(self, 200, generate(payload)) except Exception as error: - write_json(self, 500, { - "ok": False, - "error": str(error), - "traceback": traceback.format_exc(), - }) + write_json(self, 500, text_encoding.worker_error_body(error)) def log_message(self, fmt, *values): print(f"{self.address_string()} - {fmt % values}", flush=True) diff --git a/scripts/test_text_encoding.py b/scripts/test_text_encoding.py index 65aa1e3..9619ae2 100644 --- a/scripts/test_text_encoding.py +++ b/scripts/test_text_encoding.py @@ -180,6 +180,12 @@ class ChildBoundary(unittest.TestCase): """) self.assertEqual(raised.exception.free_vram, 1234) + def test_worker_error_body_carries_the_code(self): + busy = te.worker_error_body(te.GpuBusy(GIB)) + self.assertIs(busy["ok"], False) + self.assertEqual(busy["code"], "gpu-busy") + self.assertIsNone(te.worker_error_body(RuntimeError("x"))["code"]) + def test_other_failures_name_the_exit_code_and_never_the_text(self): with self.assertRaises(RuntimeError) as raised: self.run_stub("import sys; sys.exit(1)", texts=(SECRET,)) diff --git a/scripts/text_encoding.py b/scripts/text_encoding.py index 668e529..572cdad 100644 --- a/scripts/text_encoding.py +++ b/scripts/text_encoding.py @@ -12,6 +12,7 @@ import subprocess import sys import tempfile import time +import traceback from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -33,7 +34,11 @@ LLAMA_BASE = "meta-llama/Meta-Llama-3-8B-Instruct" # the base model the mntp ad class GpuBusy(RuntimeError): - """The card has too little free VRAM to host the encoder; there is no CPU fallback.""" + """Placement refusal: too little free VRAM to host the encoder, and no CPU fallback. + + Whether heavy work may start at all is the daemon's tenancy gate, which sees the host; this is only + the child sizing its own placement from what torch can see. + """ code = "gpu-busy" @@ -49,6 +54,11 @@ def plan_placement(free_vram: int, mem_available: int) -> dict: return {0: free_vram - GPU_RESERVE, "cpu": max(mem_available - CPU_RESERVE, 0)} +def worker_error_body(error: BaseException) -> dict: + """The worker's one error wire shape: a typed code (GpuBusy.code, else None) beside the text.""" + return {"ok": False, "error": str(error), "code": getattr(error, "code", None), "traceback": traceback.format_exc()} + + def read_snapshot_revision(hf_home: Path, repo_id: str) -> str: """The commit the mounted Hugging Face cache resolves a repo's main ref to.""" ref = Path(hf_home) / "hub" / ("models--" + repo_id.replace("/", "--")) / "refs" / "main" From a9a449225c593e72b55b0ec11149d9380a19bae8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:13:50 +0200 Subject: [PATCH 04/26] Docs: GPU tenancy paragraph, placement wording, 503 gpu-busy contract Drops the claim that the child's 2 GiB check refuses under contention (closes gpu-busy-blind's README overstatement). Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++++++++++++++++-- docs/verse-service-contract.md | 8 +++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 801ff26..973efea 100644 --- a/README.md +++ b/README.md @@ -159,12 +159,26 @@ Kimodo normalizes the prompt before encoding (`A person shrugs sadly.`), and the cache keys on that text. A request's misses go to one transient child, `scripts/kimodo-text-encode.py`: it loads the encoder in bf16 on the GPU, with the layers that do not fit in free VRAM held in RAM, encodes, writes its answer and -exits, so the OS reclaims its RAM and VRAM. It refuses with a typed `gpu-busy` -below 2 GiB of free VRAM and has no CPU fallback; hits never touch it. Each +exits, so the OS reclaims its RAM and VRAM. It sizes its own placement from the free VRAM torch +can see and refuses a placement below 2 GiB with a typed `gpu-busy` (source `placement`); +it has no CPU fallback, and hits never touch it. Each `/motion/generate` job carries the encoder record (hits, misses, seconds, vector hashes) and the worker identity. `GET /health` shows the identity and the stored count. +GPU tenancy. Raven's GPU is the household's first. The daemon (Windows, in the +console session) samples, every 15 seconds, the full-screen state, the 3D engine's use and +the host's free VRAM, as numbers only, through one hidden PowerShell child +(`scripts/gpu-tenancy-probe.ps1`). A new `POST /motion/generate` is refused with HTTP 503, +`retry-after: 60` and `{ ok: false, code: "gpu-busy", source: "admission", gpu }` while the GPU +is in use, and the consumer owns the retry. The GPU counts as in use at boot, on any full-screen +sample, on free VRAM under 10 GiB, or on 3D use above 20% for two samples in a row, and it +counts as free only after every sample for 3 minutes is clear. A job already running finishes; +the gate never stops work. `operator_state.gpu` is `{ inUse, since, checkedAt, probeOk }`; it +never names a program, and a probe that goes silent reads as in use with `probeOk` false. +`VILI_GPU_SAMPLE_MS`, `VILI_GPU_CLEAR_HOLD_MS`, `VILI_GPU_MIN_FREE_MIB` and +`VILI_GPU_UTIL3D_BUSY_PCT` override the thresholds. `npm test` runs the gate's tests. + Measured on Raven on `2026-10-22`, for a 2 second clip at 20 diffusion steps: the worker is healthy 29 seconds after `docker rm` (was 179) and rests at 2.3 GiB resident (was 10.4 GiB, with 5.8 GB swapped). A cache miss takes 59 to 103 diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 25bb563..722b060 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -45,7 +45,13 @@ promoted from JSON once that request/receipt shape stabilizes. `POST /motion/generate` accepts an explicit animation request and forwards the supported Kimodo generation controls to the resident worker. Vili owns job identity, request persistence, worker lifecycle, timeout handling, and the -artifact stem. Kimodo owns synthesis and model-specific export. +artifact stem. Kimodo owns synthesis and model-specific export. Vili also owns GPU +tenancy admission: while Raven's GPU is in use by the household, `POST /motion/generate` +answers HTTP 503 with `retry-after: 60` and `{ "ok": false, "code": "gpu-busy", "source": +"admission" | "placement", "gpu": { inUse, since, checkedAt, probeOk }, "jobId"? }` +(`jobId` only for `placement`, where the job file was written). The `code` field is the +only carrier; a consumer retries after `retry-after` and never matches error text. A job +already running finishes. ```json { From 2b5b900eca6f7ff02baca14c587b1390cd3cd4ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:22:08 +0200 Subject: [PATCH 05/26] Tests: free the gate through a real clear hold before asserting transitions The hold is measured between consecutive samples; a 180 s gap is a stale probe, not a hold. Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-tenancy.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index 6e56f83..e6a48a0 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -23,16 +23,13 @@ function rig(options = {}) { function freeRig(options) { const r = rig(options); - r.feed(CLEAR); - r.advance(180 * SEC); - r.feed(CLEAR); + for (let i = 0; i < 13; i += 1) r.feed(CLEAR); // the 13th sample is 180 s after the first assert.equal(r.gate.status().inUse, false, "precondition: gate is free"); return r; } test("hysteresis: busy holds for the clear window", () => { - const r = rig(); - r.feed(CLEAR); + const r = freeRig(); r.feed({ ...CLEAR, quns: 3 }); const busyAt = r.at(); assert.deepEqual(r.gate.status(), { inUse: true, since: busyAt, checkedAt: busyAt, probeOk: true }); @@ -118,9 +115,7 @@ test("thresholds from the options are honored", () => { test("start and stale fail closed", () => { const r = rig(); assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: true }); - r.feed(CLEAR); - r.advance(180 * SEC); - r.feed(CLEAR); + for (let i = 0; i < 13; i += 1) r.feed(CLEAR); assert.equal(r.gate.status().inUse, false); const lastSample = r.at(); From d9d396748d06d2104f4e0241745489635c225553 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:24:20 +0200 Subject: [PATCH 06/26] Tests: the clear hold counts from the first clear sample Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-tenancy.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index e6a48a0..891eda3 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -41,12 +41,15 @@ test("hysteresis: busy holds for the clear window", () => { // a busy sample late in the hold restarts it r.feed({ ...CLEAR, quns: 2 }); const restartAt = r.at(); - for (let elapsed = 15; elapsed < 180; elapsed += 15) { + r.feed(CLEAR); // the hold counts from the first clear sample + const holdStart = r.at(); + for (let elapsed = 15; elapsed <= 165; elapsed += 15) { r.feed(CLEAR); - assert.equal(r.gate.status().inUse, true, `restarted hold, ${elapsed} s`); + assert.equal(r.gate.status().inUse, true, `restarted hold, ${elapsed} s clear`); } r.feed(CLEAR); - assert.equal(r.at() - restartAt, 180 * SEC); + assert.equal(r.at() - holdStart, 180 * SEC); + assert.ok(r.at() > restartAt); const freedAt = r.at(); assert.deepEqual(r.gate.status(), { inUse: false, since: freedAt, checkedAt: freedAt, probeOk: true }); r.feed(CLEAR); From d921731a11353d9704cb4e40f4213b49669c43b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:27:32 +0200 Subject: [PATCH 07/26] Tests: kill the mutants that were real (threshold edges, stale since, probe argv and respawn timing) Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-tenancy.mjs | 43 +++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index 891eda3..d585368 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -85,6 +85,13 @@ test("sustained 3D, immediate full-screen and VRAM", () => { edge.feed({ ...CLEAR, util3d: 20 }); edge.feed({ ...CLEAR, util3d: 20 }); assert.equal(edge.gate.status().inUse, false, "exactly the threshold is not above it"); + edge.feed({ ...CLEAR, util3d: 80 }); + edge.feed({ ...CLEAR, util3d: 20 }); + assert.equal(edge.gate.status().inUse, false, "high then exactly the threshold"); + const edgeUp = freeRig(); + edgeUp.feed({ ...CLEAR, util3d: 20 }); + edgeUp.feed({ ...CLEAR, util3d: 80 }); + assert.equal(edgeUp.gate.status().inUse, false, "exactly the threshold then high"); for (const [name, sample] of [ ["QUNS_BUSY", { ...CLEAR, quns: 2 }], @@ -140,6 +147,13 @@ test("start and stale fail closed", () => { assert.equal(r.gate.status().inUse, false); }); +test("a stale start keeps since at the start when samples resume busy", () => { + const r = rig(); + r.advance(60 * SEC); + r.feed({ ...CLEAR, quns: 3 }, 0); + assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: r.at(), probeOk: true }); +}); + test("a gate that never hears from its probe stays in use and not ok", () => { const r = rig(); r.advance(46 * SEC); @@ -225,7 +239,7 @@ function fakeSpawn() { const spawn = (command, args, options) => { const child = new EventEmitter(); child.stdout = new EventEmitter(); - child.stdout.setEncoding = () => {}; + child.stdout.setEncoding = (encoding) => { child.encoding = encoding; }; spawned.push({ command, args, options, child }); return child; }; @@ -241,8 +255,11 @@ test("probe: one hidden child, lines become samples, junk is dropped", () => { assert.equal(command, "powershell.exe"); assert.equal(options.windowsHide, true); assert.deepEqual(options.stdio, ["ignore", "pipe", "ignore"]); - assert.deepEqual(args.slice(args.indexOf("-ParentPid")), ["-ParentPid", "4242", "-IntervalMs", "15000"]); - assert.ok(args.includes("-File") && args.includes("gpu-tenancy-probe.ps1")); + assert.deepEqual(args, [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", "gpu-tenancy-probe.ps1", "-ParentPid", "4242", "-IntervalMs", "15000", + ]); + assert.equal(child.encoding, "utf8"); child.stdout.emit("data", '{"quns":1,"freeMiB":14000,"util3d":2}\n{"quns":2,"free'); child.stdout.emit("data", 'MiB":9000,"util3d":3}\r\ngarbage\n'); @@ -259,11 +276,27 @@ test("probe: respawns at most once per interval after the child exits", async () spawned[0].child.emit("exit", 1); spawned[0].child.emit("error", new Error("again")); spawned[0].child.emit("exit", 1); - assert.equal(spawned.length, 1, "no immediate respawn"); - await new Promise((resolve) => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(spawned.length, 1, "no respawn inside the interval"); + await new Promise((resolve) => setTimeout(resolve, 170)); assert.equal(spawned.length, 2, "one respawn for one death, however many events"); probe.stop(); spawned[1].child.emit("exit", 1); await new Promise((resolve) => setTimeout(resolve, 200)); assert.equal(spawned.length, 2, "a stopped probe does not respawn"); }); + +test("probe: a plain exit and a spawn error each respawn", async () => { + const exited = fakeSpawn(); + const a = startProbe({ spawn: exited.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); + exited.spawned[0].child.emit("exit", 0); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(exited.spawned.length, 2); + a.stop(); + const failed = fakeSpawn(); + const b = startProbe({ spawn: failed.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); + failed.spawned[0].child.emit("error", new Error("ENOENT")); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(failed.spawned.length, 2); + b.stop(); +}); From 62485dab155c0f11aa9c7d024cee4e28b7bd3e12 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:32:09 +0200 Subject: [PATCH 08/26] Probe: read nvidia-smi's exit code after the pipeline drains Select-Object -First 1 closes the pipe early and leaves LASTEXITCODE -1, so every iteration threw and the probe wrote no samples (found on Raven). Co-Authored-By: Claude Opus 5.5 --- scripts/gpu-tenancy-probe.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/gpu-tenancy-probe.ps1 b/scripts/gpu-tenancy-probe.ps1 index c80bf2b..1c95cfd 100644 --- a/scripts/gpu-tenancy-probe.ps1 +++ b/scripts/gpu-tenancy-probe.ps1 @@ -8,8 +8,9 @@ while ($true) { try { $quns = 0 if ([Vili.Shell]::SHQueryUserNotificationState([ref]$quns) -ne 0) { throw 'quns' } - $free = nvidia-smi.exe --query-gpu=memory.free --format=csv,noheader,nounits | Select-Object -First 1 + $smi = @(nvidia-smi.exe --query-gpu=memory.free --format=csv,noheader,nounits) if ($LASTEXITCODE -ne 0) { throw 'smi' } + $free = $smi[0] $sum = (Get-Counter '\GPU Engine(*engtype_3D)\Utilization Percentage' -SampleInterval 1 -MaxSamples 1).CounterSamples | Measure-Object -Property CookedValue -Sum $line = [ordered]@{ quns = [int]$quns; freeMiB = [int]$free; util3d = [math]::Min(100, [math]::Round([double]$sum.Sum, 1)) } | ConvertTo-Json -Compress [Console]::Out.WriteLine($line) From 9ec048844b5b4e09b3f2076bfba535b064ef4b9e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 00:19:07 +0200 Subject: [PATCH 09/26] Probe: own module, 3D counter path as a tested constant, engines count, hung-child watchdog The probe's sample plumbing leaves the gate module (the gate never kills; the probe may kill its own hung child). The counter path is one exported constant passed to the script as -Util3dPath, the script reports how many 3D engine counters resolved, and a child that is alive but silent for 3 intervals is killed and respawned. Co-Authored-By: Claude Opus 5.5 --- scripts/gpu-probe.mjs | 84 +++++++++++++++++ scripts/gpu-tenancy-probe.ps1 | 11 ++- scripts/test-gpu-probe.mjs | 167 ++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 scripts/gpu-probe.mjs create mode 100644 scripts/test-gpu-probe.mjs diff --git a/scripts/gpu-probe.mjs b/scripts/gpu-probe.mjs new file mode 100644 index 0000000..e9ee370 --- /dev/null +++ b/scripts/gpu-probe.mjs @@ -0,0 +1,84 @@ +// The sample source for the GPU tenancy gate: one long-lived hidden PowerShell child in the daemon's +// own session. It decides nothing; it only turns the child's stdout into samples and keeps the child +// alive. + +// The 3D-engine counter the probe reads. A wrong path reads as zero without an error, so the path is +// one constant, pinned by a test, and the probe reports how many engine counters it resolved. +export const UTIL3D_COUNTER_PATH = String.raw`\GPU Engine(*engtype_3D)\Utilization Percentage`; + +// A probe line is JSON; only the four finite numbers survive, so nothing else can reach the gate. +export function parseSample(line) { + let value; + try { + value = JSON.parse(line); + } catch { + return null; + } + if (value === null || typeof value !== "object") return null; + const { quns, freeMiB, util3d, engines } = value; + for (const field of [quns, freeMiB, util3d, engines]) { + if (typeof field !== "number" || !Number.isFinite(field)) return null; + } + return { quns, freeMiB, util3d, engines }; +} + +// The child exits with the daemon (the script watches the parent pid). If it exits on its own it is +// respawned at most once per interval. A child that stays alive but sends no sample for staleIntervals +// intervals is hung: it is killed and respawned the same way. +export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid = process.pid, staleIntervals = 3 }) { + let stopped = false; + let lastSpawnAt = 0; + let lastSampleAt = 0; + let current = null; + + function scheduleRespawn(entry) { + if (entry.respawned) return; + entry.respawned = true; + if (current === entry) current = null; + setTimeout(launch, Math.max(0, lastSpawnAt + intervalMs - Date.now())).unref(); + } + + function launch() { + if (stopped) return; + lastSpawnAt = Date.now(); + lastSampleAt = lastSpawnAt; + const child = spawn("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", scriptPath, + "-ParentPid", String(parentPid), + "-IntervalMs", String(intervalMs), + "-Util3dPath", UTIL3D_COUNTER_PATH, + ], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }); + const entry = { child, respawned: false }; + current = entry; + let pending = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const line of lines) { + const sample = parseSample(line.trim()); + if (sample) { + lastSampleAt = Date.now(); + onSample(sample); + } + } + }); + child.on("exit", () => scheduleRespawn(entry)); + child.on("error", () => scheduleRespawn(entry)); + } + + const watchdog = setInterval(() => { + if (stopped || current === null || Date.now() - lastSampleAt <= staleIntervals * intervalMs) return; + const hung = current; + scheduleRespawn(hung); + hung.child.kill(); + }, intervalMs); + watchdog.unref(); + + launch(); + return { stop: () => { stopped = true; clearInterval(watchdog); } }; +} diff --git a/scripts/gpu-tenancy-probe.ps1 b/scripts/gpu-tenancy-probe.ps1 index 1c95cfd..e172b37 100644 --- a/scripts/gpu-tenancy-probe.ps1 +++ b/scripts/gpu-tenancy-probe.ps1 @@ -1,5 +1,7 @@ -# One sample line per interval: {"quns":n,"freeMiB":n,"util3d":n}. Numbers only; no process identity is read. -param([int]$ParentPid, [int]$IntervalMs = 15000) +# One sample line per interval: {"quns":n,"freeMiB":n,"util3d":n,"engines":n}. Numbers only; no process +# identity is read. engines is how many 3D-engine counters the path resolved: zero means the path is +# wrong, and the gate treats that as a probe fault. +param([int]$ParentPid, [int]$IntervalMs = 15000, [string]$Util3dPath) $ErrorActionPreference = 'Stop' Add-Type -Namespace Vili -Name Shell -MemberDefinition '[DllImport("shell32.dll")] public static extern int SHQueryUserNotificationState(out int state);' while ($true) { @@ -11,8 +13,9 @@ while ($true) { $smi = @(nvidia-smi.exe --query-gpu=memory.free --format=csv,noheader,nounits) if ($LASTEXITCODE -ne 0) { throw 'smi' } $free = $smi[0] - $sum = (Get-Counter '\GPU Engine(*engtype_3D)\Utilization Percentage' -SampleInterval 1 -MaxSamples 1).CounterSamples | Measure-Object -Property CookedValue -Sum - $line = [ordered]@{ quns = [int]$quns; freeMiB = [int]$free; util3d = [math]::Min(100, [math]::Round([double]$sum.Sum, 1)) } | ConvertTo-Json -Compress + $counters = @((Get-Counter $Util3dPath -SampleInterval 1 -MaxSamples 1).CounterSamples) + $sum = $counters | Measure-Object -Property CookedValue -Sum + $line = [ordered]@{ quns = [int]$quns; freeMiB = [int]$free; util3d = [math]::Min(100, [math]::Round([double]$sum.Sum, 1)); engines = $counters.Count } | ConvertTo-Json -Compress [Console]::Out.WriteLine($line) [Console]::Out.Flush() } catch { } diff --git a/scripts/test-gpu-probe.mjs b/scripts/test-gpu-probe.mjs new file mode 100644 index 0000000..c15c5f9 --- /dev/null +++ b/scripts/test-gpu-probe.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { UTIL3D_COUNTER_PATH, parseSample, startProbe } from "./gpu-probe.mjs"; +import { createGpuTenancy } from "./gpu-tenancy.mjs"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +test("no program name canary", () => { + const canary = "canary-vili-7f3a"; + const line = JSON.stringify({ quns: 3, freeMiB: 2048, util3d: 55.5, engines: 8, name: canary, process: canary, instance: canary }); + const sample = parseSample(line); + assert.deepEqual(sample, { quns: 3, freeMiB: 2048, util3d: 55.5, engines: 8 }); + assert.deepEqual(Object.keys(sample).sort(), ["engines", "freeMiB", "quns", "util3d"]); + const gate = createGpuTenancy({ now: () => 1_000_000 }); + gate.observe(sample); + assert.equal(JSON.stringify(gate.status()).includes(canary), false); +}); + +test("parseSample drops anything that is not four finite numbers", () => { + const ok = { quns: 1, freeMiB: 1, util3d: 1, engines: 1 }; + assert.deepEqual(parseSample(JSON.stringify(ok)), ok); + assert.equal(parseSample("not json"), null); + assert.equal(parseSample("null"), null); + assert.equal(parseSample("[1,2,3]"), null); + assert.equal(parseSample(""), null); + for (const field of Object.keys(ok)) { + const missing = { ...ok }; + delete missing[field]; + assert.equal(parseSample(JSON.stringify(missing)), null, `${field} missing`); + assert.equal(parseSample(JSON.stringify({ ...ok, [field]: "1" })), null, `${field} as a string`); + } + assert.equal(parseSample('{"quns":1,"freeMiB":1,"util3d":1e999,"engines":1}'), null); +}); + +test("the 3D counter path is the one constant, and the script has no copy of it", async () => { + assert.equal(UTIL3D_COUNTER_PATH, "\\GPU Engine(*engtype_3D)\\Utilization Percentage"); + const script = await readFile(new URL("./gpu-tenancy-probe.ps1", import.meta.url), "utf8"); + assert.equal(/GPU Engine|engtype/.test(script.replace(/^#.*$/gm, "")), false, "the path reaches the script as -Util3dPath only"); + assert.match(script, /Get-Counter \$Util3dPath/); + assert.match(script, /engines = \$counters\.Count/); +}); + +test("a probe that resolved no 3D counters keeps the gate failing closed", () => { + let t = 1_000_000; + const gate = createGpuTenancy({ now: () => t }); + const line = (engines) => JSON.stringify({ quns: 1, freeMiB: 14000, util3d: 0, engines }); + for (let i = 0; i < 20; i += 1) { + t += 15_000; + gate.observe(parseSample(line(0))); + } + assert.deepEqual(gate.status(), { inUse: true, since: 1_000_000, checkedAt: null, probeOk: false }); + for (let i = 0; i < 13; i += 1) { + t += 15_000; + gate.observe(parseSample(line(8))); + } + assert.equal(gate.status().inUse, false, "the same clear samples with a resolved counter do clear the gate"); + assert.equal(gate.status().probeOk, true); +}); + +function fakeSpawn() { + const spawned = []; + const spawn = (command, args, options) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stdout.setEncoding = (encoding) => { child.encoding = encoding; }; + child.kills = 0; + child.kill = () => { child.kills += 1; }; + spawned.push({ command, args, options, child }); + return child; + }; + return { spawn, spawned }; +} + +test("probe: one hidden child, lines become samples, junk is dropped", () => { + const { spawn, spawned } = fakeSpawn(); + const samples = []; + const probe = startProbe({ spawn, scriptPath: "gpu-tenancy-probe.ps1", intervalMs: 15000, onSample: (s) => samples.push(s), parentPid: 4242 }); + assert.equal(spawned.length, 1); + const { command, args, options, child } = spawned[0]; + assert.equal(command, "powershell.exe"); + assert.equal(options.windowsHide, true); + assert.deepEqual(options.stdio, ["ignore", "pipe", "ignore"]); + assert.deepEqual(args, [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", "gpu-tenancy-probe.ps1", "-ParentPid", "4242", "-IntervalMs", "15000", + "-Util3dPath", UTIL3D_COUNTER_PATH, + ]); + assert.equal(child.encoding, "utf8"); + + child.stdout.emit("data", '{"quns":1,"freeMiB":14000,"util3d":2,"engines":4}\n{"quns":2,"free'); + child.stdout.emit("data", 'MiB":9000,"util3d":3,"engines":4}\r\ngarbage\n'); + assert.deepEqual(samples, [ + { quns: 1, freeMiB: 14000, util3d: 2, engines: 4 }, + { quns: 2, freeMiB: 9000, util3d: 3, engines: 4 }, + ]); + probe.stop(); +}); + +test("probe: respawns at most once per interval after the child exits", async () => { + const { spawn, spawned } = fakeSpawn(); + const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 80, onSample: () => {} }); + spawned[0].child.emit("exit", 1); + spawned[0].child.emit("error", new Error("again")); + spawned[0].child.emit("exit", 1); + await sleep(30); + assert.equal(spawned.length, 1, "no respawn inside the interval"); + await sleep(170); + assert.equal(spawned.length, 2, "one respawn for one death, however many events"); + probe.stop(); + spawned[1].child.emit("exit", 1); + await sleep(200); + assert.equal(spawned.length, 2, "a stopped probe does not respawn"); +}); + +test("probe: a plain exit and a spawn error each respawn", async () => { + const exited = fakeSpawn(); + const a = startProbe({ spawn: exited.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); + exited.spawned[0].child.emit("exit", 0); + await sleep(120); + assert.equal(exited.spawned.length, 2); + a.stop(); + const failed = fakeSpawn(); + const b = startProbe({ spawn: failed.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); + failed.spawned[0].child.emit("error", new Error("ENOENT")); + await sleep(120); + assert.equal(failed.spawned.length, 2); + b.stop(); +}); + +test("probe watchdog: a child that stays alive but sends nothing is killed and respawned once", async () => { + const { spawn, spawned } = fakeSpawn(); + const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 40, onSample: () => {} }); + await sleep(90); + assert.equal(spawned[0].child.kills, 0, "under 3 intervals of silence the child is left alone"); + assert.equal(spawned.length, 1); + await sleep(200); + assert.equal(spawned[0].child.kills, 1, "the hung child is killed exactly once"); + assert.equal(spawned.length, 2, "and replaced"); + spawned[0].child.emit("exit", 1); + const line = '{"quns":1,"freeMiB":14000,"util3d":1,"engines":4} +'; + for (let i = 0; i < 4; i += 1) { + await sleep(40); + spawned[1].child.stdout.emit("data", line); + } + assert.equal(spawned.length, 2, "the killed child's exit adds no second respawn"); + assert.equal(spawned[1].child.kills, 0); + probe.stop(); +}); + +test("probe watchdog: samples keep a child alive, and a dead child is not killed again", async () => { + const { spawn, spawned } = fakeSpawn(); + const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 40, onSample: () => {} }); + const line = '{"quns":1,"freeMiB":14000,"util3d":1,"engines":4}\n'; + for (let i = 0; i < 10; i += 1) { + await sleep(40); + spawned[0].child.stdout.emit("data", line); + } + assert.equal(spawned[0].child.kills, 0, "a child that samples is never killed"); + assert.equal(spawned.length, 1); + spawned[0].child.emit("exit", 1); + await sleep(400); + assert.equal(spawned[0].child.kills, 0, "a child that already exited is not killed"); + probe.stop(); +}); From 5050de58b5a22732c50d449635e4551778013a3e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 00:19:08 +0200 Subject: [PATCH 10/26] Gate: one slot judged where the GPU is taken, own VRAM discounted, truthful placement body, env refusal acquire() judges at the slot: household signals first (admission), then a held slot (slot); nothing queues, so a request can no longer wait on the worker lock past a changed gate. While Vili's job holds the slot, and until free VRAM recovers to its pre-job level (capped), low free VRAM is not the household's; full-screen and 3D always count. A sample with no resolved 3D counters is a probe fault and the gate fails closed. The placement 503 carries no gate status. A malformed VILI_GPU_* override refuses to start, naming the variable. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++-- docs/verse-service-contract.md | 15 +- package.json | 2 +- scripts/gpu-tenancy.mjs | 143 ++++++++++--------- scripts/test-gpu-gate-http.mjs | 22 +++ scripts/test-gpu-tenancy.mjs | 247 +++++++++++++++++++++------------ scripts/vili-daemon.mjs | 18 +-- 7 files changed, 286 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 973efea..c20675a 100644 --- a/README.md +++ b/README.md @@ -169,15 +169,22 @@ count. GPU tenancy. Raven's GPU is the household's first. The daemon (Windows, in the console session) samples, every 15 seconds, the full-screen state, the 3D engine's use and the host's free VRAM, as numbers only, through one hidden PowerShell child -(`scripts/gpu-tenancy-probe.ps1`). A new `POST /motion/generate` is refused with HTTP 503, -`retry-after: 60` and `{ ok: false, code: "gpu-busy", source: "admission", gpu }` while the GPU -is in use, and the consumer owns the retry. The GPU counts as in use at boot, on any full-screen -sample, on free VRAM under 10 GiB, or on 3D use above 20% for two samples in a row, and it -counts as free only after every sample for 3 minutes is clear. A job already running finishes; -the gate never stops work. `operator_state.gpu` is `{ inUse, since, checkedAt, probeOk }`; it -never names a program, and a probe that goes silent reads as in use with `probeOk` false. -`VILI_GPU_SAMPLE_MS`, `VILI_GPU_CLEAR_HOLD_MS`, `VILI_GPU_MIN_FREE_MIB` and -`VILI_GPU_UTIL3D_BUSY_PCT` override the thresholds. `npm test` runs the gate's tests. +(`scripts/gpu-tenancy-probe.ps1`, supervised by `scripts/gpu-probe.mjs`: a child that exits or +sends nothing for 3 intervals is replaced). The daemon holds one GPU slot, and a +`POST /motion/generate` is judged when it reaches the slot. It is refused with HTTP 503, +`retry-after: 60` and `{ ok: false, code: "gpu-busy", source: "admission", gpu }` while the +household is using the GPU, or `{ ok: false, code: "gpu-busy", source: "slot" }` while another job +holds the slot; nothing queues, and the consumer owns the retry. The GPU counts as in use at +boot, on any full-screen sample, on free VRAM under 10 GiB, or on 3D use above 20% for two +samples in a row, and it counts as free only after every sample for 3 minutes is clear. Vili's own +job takes most of the VRAM, so while a job holds the slot, and until the free VRAM has recovered to +within 1 GiB of what it was before the job (at most 60 seconds), low free VRAM is not counted +against the household; full-screen and 3D use always count. A job already running finishes; the +gate never stops work. `operator_state.gpu` is `{ inUse, since, checkedAt, probeOk }`; it never +names a program, and a probe that goes silent, or finds no 3D engine counters, reads as in use +with `probeOk` false. `VILI_GPU_SAMPLE_MS`, `VILI_GPU_CLEAR_HOLD_MS`, `VILI_GPU_MIN_FREE_MIB` and +`VILI_GPU_UTIL3D_BUSY_PCT` override the thresholds; a value that is not a finite number refuses +to start the daemon. `npm test` runs the gate's tests. Measured on Raven on `2026-10-22`, for a 2 second clip at 20 diffusion steps: the worker is healthy 29 seconds after `docker rm` (was 179) and rests at 2.3 GiB diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 722b060..f53a54c 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -46,12 +46,15 @@ promoted from JSON once that request/receipt shape stabilizes. supported Kimodo generation controls to the resident worker. Vili owns job identity, request persistence, worker lifecycle, timeout handling, and the artifact stem. Kimodo owns synthesis and model-specific export. Vili also owns GPU -tenancy admission: while Raven's GPU is in use by the household, `POST /motion/generate` -answers HTTP 503 with `retry-after: 60` and `{ "ok": false, "code": "gpu-busy", "source": -"admission" | "placement", "gpu": { inUse, since, checkedAt, probeOk }, "jobId"? }` -(`jobId` only for `placement`, where the job file was written). The `code` field is the -only carrier; a consumer retries after `retry-after` and never matches error text. A job -already running finishes. +tenancy admission: the daemon holds one GPU slot, and `POST /motion/generate` is judged +when it reaches the slot. It answers HTTP 503 with `retry-after: 60` and one of three bodies, by +`source`: `{ "ok": false, "code": "gpu-busy", "source": "admission", "gpu": { inUse, since, +checkedAt, probeOk } }` while the household is using the GPU; `{ "ok": false, "code": +"gpu-busy", "source": "slot" }` while another job holds the slot (nothing queues); and `{ +"ok": false, "code": "gpu-busy", "source": "placement", "jobId" }` when the encoder child +refused its own placement, where the job file was written and no household status is +claimed. The `code` field is the only carrier; a consumer retries after `retry-after` and +never matches error text. A job already running finishes. ```json { diff --git a/package.json b/package.json index 4164e53..fc6d3cb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node scripts/vili-daemon.mjs", "health": "node scripts/vili-daemon.mjs --health", - "test": "node --test scripts/test-gpu-tenancy.mjs", + "test": "node --test scripts/test-gpu-tenancy.mjs scripts/test-gpu-probe.mjs", "test:http": "node --test scripts/test-gpu-gate-http.mjs", "smoke": "node scripts/smoke-vili.mjs", "provider": "node scripts/vili-daemon.mjs --print-provider-advertisement" diff --git a/scripts/gpu-tenancy.mjs b/scripts/gpu-tenancy.mjs index 1009a66..8f8c0ce 100644 --- a/scripts/gpu-tenancy.mjs +++ b/scripts/gpu-tenancy.mjs @@ -1,6 +1,13 @@ -// GPU tenancy gate: the one owner of "may heavy GPU work start now", from the host's view. -// It reads numbers only (full-screen state, 3D-engine use, host free VRAM), keeps a yes/no and a -// timestamp, and never learns which program is running. It refuses new work; it never stops work. +// GPU tenancy gate: the one owner of "may heavy GPU work start now". It reads numbers only +// (full-screen state, 3D-engine use, host free VRAM), keeps a yes/no and a timestamp, and never +// learns which program is running. It refuses new work; it never stops work. +// +// The daemon holds one GPU slot. A job takes the slot through acquire() and the gate judges at that +// moment: the household signals say no, or another job holds the slot, and the answer is a typed +// refusal. Nothing waits. While Vili's own job holds the slot, and until its VRAM has drained after +// the slot frees, the free-VRAM signal is not the household's: the encoder child sizes itself to the +// VRAM that was free, so no VRAM sample separates Vili from a game. Full-screen and 3D-engine use +// always count. export const RETRY_AFTER_SECONDS = 60; const QUNS_BUSY = 2; @@ -13,6 +20,8 @@ export function createGpuTenancy({ minFreeMiB = 10240, util3dBusyPct = 20, staleAfterMs = 3 * sampleIntervalMs, + drainToleranceMiB = 1024, + drainCapMs = 4 * sampleIntervalMs, }) { const startedAt = now(); let inUse = true; @@ -21,10 +30,17 @@ export function createGpuTenancy({ let lastSeenAt = startedAt; let clearSince = null; let previousUtil3d = null; + let lastFreeMiB = null; + let held = false; + let baselineFreeMiB = null; + let drainingSince = null; const staleSince = () => (now() - lastSeenAt > staleAfterMs ? lastSeenAt + staleAfterMs : null); function observe(sample) { + // A probe that found no 3D engine counters is blind to the signal: a probe fault, not a clear + // sample. The sample is dropped, the gate goes stale and fails closed. + if (!(sample.engines >= 1)) return; const t = now(); const staleAt = staleSince(); if (staleAt !== null) { @@ -35,12 +51,17 @@ export function createGpuTenancy({ clearSince = null; previousUtil3d = null; } + if (drainingSince !== null && (sample.freeMiB >= baselineFreeMiB - drainToleranceMiB || t - drainingSince >= drainCapMs)) { + drainingSince = null; + } + const ownVram = held || drainingSince !== null; const sustained3d = sample.util3d > util3dBusyPct && previousUtil3d !== null && previousUtil3d > util3dBusyPct; const busy = sample.quns === QUNS_BUSY || sample.quns === QUNS_RUNNING_D3D_FULL_SCREEN - || sample.freeMiB < minFreeMiB + || (!ownVram && sample.freeMiB < minFreeMiB) || sustained3d; previousUtil3d = sample.util3d; + lastFreeMiB = sample.freeMiB; lastSeenAt = t; checkedAt = t; if (busy) { @@ -66,78 +87,66 @@ export function createGpuTenancy({ return { inUse, since, checkedAt, probeOk: true }; } - return { observe, status }; + // Judges once and either takes the slot or answers the typed refusal. The household's answer comes + // first: it is the one a consumer can act on. + function acquire() { + const gpu = status(); + if (gpu.inUse) return { refusal: { ok: false, code: "gpu-busy", source: "admission", gpu } }; + if (held) return { refusal: { ok: false, code: "gpu-busy", source: "slot" } }; + held = true; + if (drainingSince === null) baselineFreeMiB = lastFreeMiB; + return { + release() { + if (!held) return; + held = false; + drainingSince = now(); + }, + }; + } + + return { observe, status, acquire }; } -// A probe line is JSON; only the three finite numbers survive, so nothing else can reach the gate. -export function parseSample(line) { - let value; - try { - value = JSON.parse(line); - } catch { - return null; - } - if (value === null || typeof value !== "object") return null; - const { quns, freeMiB, util3d } = value; - for (const field of [quns, freeMiB, util3d]) { - if (typeof field !== "number" || !Number.isFinite(field)) return null; - } - return { quns, freeMiB, util3d }; -} - -// Checks the gate once. A busy gate refuses without calling the job; a free gate runs the job to -// its end and returns its result: the gate never holds, pauses or cancels a job. +// Takes the slot, runs the job to its end and returns its result, or returns the typed refusal +// without calling the job. The gate never holds, pauses or cancels a job. export async function admitThen(gate, job) { - const gpu = gate.status(); - if (gpu.inUse) return { ok: false, code: "gpu-busy", source: "admission", gpu }; - return job(); + const slot = gate.acquire(); + if (slot.refusal) return slot.refusal; + try { + return await job(); + } finally { + slot.release(); + } } // The encoder child's placement refusal reaches the daemon as a typed code on the worker's error body; -// it is never read out of error text. Returns the typed 503 body, or null for any other outcome. -export function placementRefusal(workerBody, gpu, jobId) { +// it is never read out of error text. Placement is the child's own view of free VRAM, not the +// household gate, so the body carries no gate status. Returns the typed 503 body, or null for any +// other outcome. +export function placementRefusal(workerBody, jobId) { if (workerBody?.code !== "gpu-busy") return null; - return { ok: false, code: "gpu-busy", source: "placement", gpu, jobId }; + return { ok: false, code: "gpu-busy", source: "placement", jobId }; } -// One long-lived hidden PowerShell child feeds samples. It exits with the daemon (the script watches -// the parent pid); if it exits on its own it is respawned at most once per interval. -export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid = process.pid }) { - let stopped = false; - let lastSpawnAt = 0; +const ENV_OPTIONS = [ + ["VILI_GPU_SAMPLE_MS", "sampleIntervalMs", true], + ["VILI_GPU_CLEAR_HOLD_MS", "clearHoldMs", false], + ["VILI_GPU_MIN_FREE_MIB", "minFreeMiB", false], + ["VILI_GPU_UTIL3D_BUSY_PCT", "util3dBusyPct", false], +]; - function launch() { - if (stopped) return; - lastSpawnAt = Date.now(); - const child = spawn("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", "Bypass", - "-File", scriptPath, - "-ParentPid", String(parentPid), - "-IntervalMs", String(intervalMs), - ], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }); - let pending = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - pending += chunk; - const lines = pending.split("\n"); - pending = lines.pop(); - for (const line of lines) { - const sample = parseSample(line.trim()); - if (sample) onSample(sample); - } - }); - let respawned = false; - const respawn = () => { - if (respawned) return; - respawned = true; - setTimeout(launch, Math.max(0, lastSpawnAt + intervalMs - Date.now())).unref(); - }; - child.on("exit", respawn); - child.on("error", respawn); +// Operator overrides. A malformed value would silently defeat a comparison (NaN is never below +// anything), so it refuses to start instead, naming the variable and never its value. +export function gpuTenancyOptionsFromEnv(env) { + const options = {}; + for (const [name, key, positive] of ENV_OPTIONS) { + const raw = env[name]; + if (raw === undefined) continue; + const value = raw.trim() === "" ? Number.NaN : Number(raw); + if (!Number.isFinite(value) || value < 0 || (positive && value === 0)) { + throw new Error(`${name} must be a finite ${positive ? "positive" : "non-negative"} number`); + } + options[key] = value; } - - launch(); - return { stop: () => { stopped = true; } }; + return options; } diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 325b1f5..3414c04 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -63,3 +63,25 @@ test("a new job is refused 503 gpu-busy with retry-after, creating nothing; vali await rm(stateRoot, { recursive: true, force: true }); } }); + +test("a malformed GPU override refuses to start and names the variable, not its value", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-env-")); + const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(port + 1), "--state-root", stateRoot], { + env: { ...process.env, VILI_GPU_MIN_FREE_MIB: "10240MiB-secret" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + try { + const code = await new Promise((resolve) => child.on("exit", resolve)); + assert.notEqual(code, 0); + assert.match(output, /VILI_GPU_MIN_FREE_MIB must be a finite non-negative number/); + assert.equal(output.includes("10240MiB-secret"), false); + assert.equal(output.includes("Vili listening"), false); + } finally { + child.kill(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index d585368..840c95a 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -1,10 +1,9 @@ import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; import test from "node:test"; -import { admitThen, createGpuTenancy, parseSample, placementRefusal, startProbe } from "./gpu-tenancy.mjs"; +import { admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, placementRefusal } from "./gpu-tenancy.mjs"; const SEC = 1000; -const CLEAR = { quns: 1, freeMiB: 14000, util3d: 1 }; +const CLEAR = { quns: 1, freeMiB: 14000, util3d: 1, engines: 8 }; function rig(options = {}) { let t = 1_000_000; @@ -203,100 +202,166 @@ test("typed busy result", async () => { assert.equal(calls, 0); }); -test("placement refusal is keyed on the typed code, never on text", () => { - const gpu = { inUse: false, since: 1, checkedAt: 2, probeOk: true }; - assert.deepEqual(placementRefusal({ ok: false, code: "gpu-busy", error: "x" }, gpu, "vili-1"), { - ok: false, code: "gpu-busy", source: "placement", gpu, jobId: "vili-1", +test("placement refusal is keyed on the typed code, never on text, and carries no gate status", () => { + assert.deepEqual(placementRefusal({ ok: false, code: "gpu-busy", error: "x" }, "vili-1"), { + ok: false, code: "gpu-busy", source: "placement", jobId: "vili-1", }); - assert.equal(placementRefusal({ ok: false, error: "gpu-busy: 0.50 GiB of VRAM free" }, gpu, "vili-1"), null); - assert.equal(placementRefusal({ ok: false, code: null }, gpu, "vili-1"), null); - assert.equal(placementRefusal(null, gpu, "vili-1"), null); + assert.equal(placementRefusal({ ok: false, error: "gpu-busy: 0.50 GiB of VRAM free" }, "vili-1"), null); + assert.equal(placementRefusal({ ok: false, code: null }, "vili-1"), null); + assert.equal(placementRefusal(null, "vili-1"), null); }); -test("no program name canary", () => { - const canary = "canary-vili-7f3a"; - const line = JSON.stringify({ quns: 3, freeMiB: 2048, util3d: 55.5, name: canary, process: canary, instance: canary }); - const sample = parseSample(line); - assert.deepEqual(sample, { quns: 3, freeMiB: 2048, util3d: 55.5 }); - assert.deepEqual(Object.keys(sample).sort(), ["freeMiB", "quns", "util3d"]); + +// ---- the slot: one job at a time, judged where the GPU is taken ---- + +const OWN = { ...CLEAR, freeMiB: 1800 }; // the encoder child holds the VRAM + +test("slot: a second request while a job runs is refused as a slot refusal and never waits", async () => { + const r = freeRig(); + let release; + const running = admitThen(r.gate, () => new Promise((resolve) => { release = () => resolve("done"); })); + let calls = 0; + const refused = await admitThen(r.gate, async () => { calls += 1; }); + assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "slot" }); + assert.equal(calls, 0); + const third = await admitThen(r.gate, async () => { calls += 1; }); + assert.equal(third.source, "slot"); + release(); + assert.equal(await running, "done"); + assert.equal(await admitThen(r.gate, async () => "next"), "next", "the slot frees when the job ends"); +}); + +test("slot: a failing job still frees the slot", async () => { + const r = freeRig(); + await assert.rejects(admitThen(r.gate, async () => { throw new Error("boom"); }), /boom/); + assert.equal(await admitThen(r.gate, async () => "after"), "after"); +}); + +test("slot: the household's answer comes before the slot's", async () => { + const r = freeRig(); + const running = admitThen(r.gate, () => new Promise(() => {})); + r.feed({ ...OWN, quns: 3 }); + const refused = await admitThen(r.gate, async () => {}); + assert.equal(refused.source, "admission"); + assert.equal(refused.gpu.inUse, true); + void running; +}); + +test("own VRAM: a sequential batch of misses is never refused for Vili's own load", async () => { + const r = freeRig(); + for (let miss = 1; miss <= 8; miss += 1) { + const result = await admitThen(r.gate, async () => { + for (let i = 0; i < 9; i += 1) r.feed(OWN); // about 2 minutes of child VRAM + return `miss ${miss}`; + }); + assert.equal(result, `miss ${miss}`, `miss ${miss} was admitted`); + r.feed(CLEAR); // the child's VRAM has drained + assert.equal(r.gate.status().inUse, false, `gate free after miss ${miss}`); + } +}); + +test("own VRAM: the next request right after a job is admitted while the child's VRAM drains", async () => { + const r = freeRig(); + await admitThen(r.gate, async () => { r.feed(OWN); r.feed(OWN); }); + r.feed(OWN); // still draining after the slot freed + assert.equal(r.gate.status().inUse, false); + const second = await admitThen(r.gate, async () => { + r.feed(OWN); + return "second"; + }); + assert.equal(second, "second"); + r.feed(CLEAR); + assert.equal(r.gate.status().inUse, false, "the baseline survives back-to-back jobs, so recovery is judged against the first"); +}); + +test("own VRAM: low VRAM that outlives the drain is the household's, and refuses", async () => { + const r = freeRig({ sampleIntervalMs: 15 * SEC }); + await admitThen(r.gate, async () => { r.feed(OWN); }); + for (let i = 0; i < 3; i += 1) { + r.feed(OWN); + assert.equal(r.gate.status().inUse, false, `draining ${i}`); + } + r.feed(OWN); // 60 s after the release: the drain cap + assert.equal(r.gate.status().inUse, true); + assert.equal((await admitThen(r.gate, async () => {})).source, "admission"); +}); + +test("own VRAM: partial recovery within the tolerance ends the drain, below it does not", async () => { + const within = freeRig(); + await admitThen(within.gate, async () => { within.feed(OWN); }); + within.feed({ ...CLEAR, freeMiB: 14000 - 1024 }); + within.feed({ ...CLEAR, freeMiB: 5000 }); + assert.equal(within.gate.status().inUse, true, "recovered, then VRAM below the minimum counts again"); + + const below = freeRig(); + await admitThen(below.gate, async () => { below.feed(OWN); }); + below.feed({ ...CLEAR, freeMiB: 14000 - 1025 }); + below.feed({ ...CLEAR, freeMiB: 5000 }); + assert.equal(below.gate.status().inUse, false, "still draining: not recovered to within the tolerance"); +}); + +test("household signals mid-job count, and the next job is refused", async () => { + for (const [name, sample] of [ + ["full-screen", { ...OWN, quns: 2 }], + ["exclusive full-screen", { ...OWN, quns: 3 }], + ]) { + const r = freeRig(); + await admitThen(r.gate, async () => { + r.feed(OWN); + r.feed(sample); + }); + const refused = await admitThen(r.gate, async () => {}); + assert.equal(refused.code, "gpu-busy", name); + assert.equal(refused.source, "admission", name); + } + const sustained = freeRig(); + await admitThen(sustained.gate, async () => { + sustained.feed({ ...OWN, util3d: 90 }); + sustained.feed({ ...OWN, util3d: 90 }); + }); + assert.equal((await admitThen(sustained.gate, async () => {})).source, "admission", "sustained 3D mid-job"); +}); + +test("household VRAM that arrives mid-job is still there when the drain ends", async () => { + const r = freeRig(); + await admitThen(r.gate, async () => { r.feed(OWN); }); + for (let i = 0; i < 4; i += 1) r.feed({ ...OWN, freeMiB: 600 }); + assert.equal(r.gate.status().inUse, true); + assert.equal((await admitThen(r.gate, async () => {})).source, "admission"); +}); + +// ---- the sample that cannot see the 3D engine ---- + +test("a sample with no resolved 3D counters is a probe fault, not a clear sample", () => { const r = rig(); - r.gate.observe(sample); - assert.equal(JSON.stringify(r.gate.status()).includes(canary), false); + for (let i = 0; i < 20; i += 1) r.feed({ ...CLEAR, engines: 0 }); + assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: false }); + r.feed({ ...CLEAR, engines: 1 }); + assert.equal(r.gate.status().checkedAt, r.at(), "one resolved counter is enough"); }); -test("parseSample drops anything that is not three finite numbers", () => { - assert.equal(parseSample("not json"), null); - assert.equal(parseSample("null"), null); - assert.equal(parseSample("[1,2,3]"), null); - assert.equal(parseSample(JSON.stringify({ quns: 1, freeMiB: 1 })), null); - assert.equal(parseSample(JSON.stringify({ quns: "1", freeMiB: 1, util3d: 1 })), null); - assert.equal(parseSample('{"quns":1,"freeMiB":1,"util3d":1e999}'), null); - assert.equal(parseSample(""), null); +// ---- operator overrides ---- + +test("env overrides: valid values are read, absent ones are left to the defaults", () => { + assert.deepEqual(gpuTenancyOptionsFromEnv({}), {}); + assert.deepEqual(gpuTenancyOptionsFromEnv({ + VILI_GPU_SAMPLE_MS: "5000", + VILI_GPU_CLEAR_HOLD_MS: "0", + VILI_GPU_MIN_FREE_MIB: "8192.5", + VILI_GPU_UTIL3D_BUSY_PCT: "35", + }), { sampleIntervalMs: 5000, clearHoldMs: 0, minFreeMiB: 8192.5, util3dBusyPct: 35 }); }); -function fakeSpawn() { - const spawned = []; - const spawn = (command, args, options) => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stdout.setEncoding = (encoding) => { child.encoding = encoding; }; - spawned.push({ command, args, options, child }); - return child; - }; - return { spawn, spawned }; -} - -test("probe: one hidden child, lines become samples, junk is dropped", () => { - const { spawn, spawned } = fakeSpawn(); - const samples = []; - const probe = startProbe({ spawn, scriptPath: "gpu-tenancy-probe.ps1", intervalMs: 15000, onSample: (s) => samples.push(s), parentPid: 4242 }); - assert.equal(spawned.length, 1); - const { command, args, options, child } = spawned[0]; - assert.equal(command, "powershell.exe"); - assert.equal(options.windowsHide, true); - assert.deepEqual(options.stdio, ["ignore", "pipe", "ignore"]); - assert.deepEqual(args, [ - "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", "gpu-tenancy-probe.ps1", "-ParentPid", "4242", "-IntervalMs", "15000", - ]); - assert.equal(child.encoding, "utf8"); - - child.stdout.emit("data", '{"quns":1,"freeMiB":14000,"util3d":2}\n{"quns":2,"free'); - child.stdout.emit("data", 'MiB":9000,"util3d":3}\r\ngarbage\n'); - assert.deepEqual(samples, [ - { quns: 1, freeMiB: 14000, util3d: 2 }, - { quns: 2, freeMiB: 9000, util3d: 3 }, - ]); - probe.stop(); -}); - -test("probe: respawns at most once per interval after the child exits", async () => { - const { spawn, spawned } = fakeSpawn(); - const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 80, onSample: () => {} }); - spawned[0].child.emit("exit", 1); - spawned[0].child.emit("error", new Error("again")); - spawned[0].child.emit("exit", 1); - await new Promise((resolve) => setTimeout(resolve, 30)); - assert.equal(spawned.length, 1, "no respawn inside the interval"); - await new Promise((resolve) => setTimeout(resolve, 170)); - assert.equal(spawned.length, 2, "one respawn for one death, however many events"); - probe.stop(); - spawned[1].child.emit("exit", 1); - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(spawned.length, 2, "a stopped probe does not respawn"); -}); - -test("probe: a plain exit and a spawn error each respawn", async () => { - const exited = fakeSpawn(); - const a = startProbe({ spawn: exited.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); - exited.spawned[0].child.emit("exit", 0); - await new Promise((resolve) => setTimeout(resolve, 120)); - assert.equal(exited.spawned.length, 2); - a.stop(); - const failed = fakeSpawn(); - const b = startProbe({ spawn: failed.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); - failed.spawned[0].child.emit("error", new Error("ENOENT")); - await new Promise((resolve) => setTimeout(resolve, 120)); - assert.equal(failed.spawned.length, 2); - b.stop(); +test("env overrides: a malformed value refuses to start and names the variable, not the value", () => { + for (const name of ["VILI_GPU_SAMPLE_MS", "VILI_GPU_CLEAR_HOLD_MS", "VILI_GPU_MIN_FREE_MIB", "VILI_GPU_UTIL3D_BUSY_PCT"]) { + for (const bad of ["10240MiB", "", " ", "NaN", "Infinity", "-5"]) { + assert.throws( + () => gpuTenancyOptionsFromEnv({ [name]: bad }), + (error) => error.message.includes(name) && (bad.trim() === "" || !error.message.includes(bad)), + `${name}=${JSON.stringify(bad)}`, + ); + } + } + assert.throws(() => gpuTenancyOptionsFromEnv({ VILI_GPU_SAMPLE_MS: "0" }), /VILI_GPU_SAMPLE_MS/, "a zero interval would spin the probe"); + assert.doesNotThrow(() => gpuTenancyOptionsFromEnv({ VILI_GPU_CLEAR_HOLD_MS: "0" })); }); diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index 34ebbcf..372b2ae 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -6,7 +6,8 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { execFile, spawn } from "node:child_process"; import { WebSocketServer } from "ws"; -import { RETRY_AFTER_SECONDS, admitThen, createGpuTenancy, placementRefusal, startProbe } from "./gpu-tenancy.mjs"; +import { startProbe } from "./gpu-probe.mjs"; +import { RETRY_AFTER_SECONDS, admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, placementRefusal } from "./gpu-tenancy.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -137,15 +138,8 @@ function parseArgs(argv) { const args = parseArgs(process.argv.slice(2)); const startedAt = new Date().toISOString(); -const envNumber = (name) => (process.env[name] === undefined ? undefined : Number(process.env[name])); -const sampleIntervalMs = envNumber("VILI_GPU_SAMPLE_MS"); -const gpuTenancy = createGpuTenancy({ - now: Date.now, - sampleIntervalMs, - clearHoldMs: envNumber("VILI_GPU_CLEAR_HOLD_MS"), - minFreeMiB: envNumber("VILI_GPU_MIN_FREE_MIB"), - util3dBusyPct: envNumber("VILI_GPU_UTIL3D_BUSY_PCT"), -}); +const gpuOptions = gpuTenancyOptionsFromEnv(process.env); +const gpuTenancy = createGpuTenancy({ now: Date.now, ...gpuOptions }); let backendStatus = { checkedAt: null, dockerImage: "unknown", @@ -670,7 +664,7 @@ async function runMotionJob(body, prompt, response) { await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true }); await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)); }); - const placement = result.ok ? null : placementRefusal(result.body, gpuTenancy.status(), jobId); + const placement = result.ok ? null : placementRefusal(result.body, jobId); if (placement) json(response, 503, placement, { "retry-after": String(RETRY_AFTER_SECONDS) }); else json(response, result.ok ? 200 : 502, job); } @@ -775,7 +769,7 @@ if (args.health) { startProbe({ spawn, scriptPath: path.join(__dirname, "gpu-tenancy-probe.ps1"), - intervalMs: sampleIntervalMs ?? 15000, + intervalMs: gpuOptions.sampleIntervalMs ?? 15000, onSample: (sample) => gpuTenancy.observe(sample), }); } From 4ac85a65a50bc593f725b2aa250227ac1bbb1135 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 00:19:58 +0200 Subject: [PATCH 11/26] Tests: fix a broken string literal in the probe watchdog test Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-probe.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test-gpu-probe.mjs b/scripts/test-gpu-probe.mjs index c15c5f9..328a3df 100644 --- a/scripts/test-gpu-probe.mjs +++ b/scripts/test-gpu-probe.mjs @@ -139,8 +139,7 @@ test("probe watchdog: a child that stays alive but sends nothing is killed and r assert.equal(spawned[0].child.kills, 1, "the hung child is killed exactly once"); assert.equal(spawned.length, 2, "and replaced"); spawned[0].child.emit("exit", 1); - const line = '{"quns":1,"freeMiB":14000,"util3d":1,"engines":4} -'; + const line = '{"quns":1,"freeMiB":14000,"util3d":1,"engines":4}\n'; for (let i = 0; i < 4; i += 1) { await sleep(40); spawned[1].child.stdout.emit("data", line); From bacc3faaa0af7fe577e2f2575a6a6f9ec370f79a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 00:20:29 +0200 Subject: [PATCH 12/26] Tests: probe respawn tests keep the watchdog out of the way; watchdog test timing Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-probe.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/test-gpu-probe.mjs b/scripts/test-gpu-probe.mjs index 328a3df..4de6ac8 100644 --- a/scripts/test-gpu-probe.mjs +++ b/scripts/test-gpu-probe.mjs @@ -116,13 +116,13 @@ test("probe: respawns at most once per interval after the child exits", async () test("probe: a plain exit and a spawn error each respawn", async () => { const exited = fakeSpawn(); - const a = startProbe({ spawn: exited.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); + const a = startProbe({ spawn: exited.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {}, staleIntervals: 1000 }); exited.spawned[0].child.emit("exit", 0); await sleep(120); assert.equal(exited.spawned.length, 2); a.stop(); const failed = fakeSpawn(); - const b = startProbe({ spawn: failed.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {} }); + const b = startProbe({ spawn: failed.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {}, staleIntervals: 1000 }); failed.spawned[0].child.emit("error", new Error("ENOENT")); await sleep(120); assert.equal(failed.spawned.length, 2); @@ -135,7 +135,7 @@ test("probe watchdog: a child that stays alive but sends nothing is killed and r await sleep(90); assert.equal(spawned[0].child.kills, 0, "under 3 intervals of silence the child is left alone"); assert.equal(spawned.length, 1); - await sleep(200); + await sleep(100); assert.equal(spawned[0].child.kills, 1, "the hung child is killed exactly once"); assert.equal(spawned.length, 2, "and replaced"); spawned[0].child.emit("exit", 1); From a2b99831835ce94bbb5d0bed3f88bb6d91ebf2e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 00:29:13 +0200 Subject: [PATCH 13/26] Mutation triage: drop two redundant guards in the probe, pin baseline, double release and env messages Co-Authored-By: Claude Opus 5.5 --- scripts/gpu-probe.mjs | 6 +++--- scripts/test-gpu-tenancy.mjs | 25 ++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/scripts/gpu-probe.mjs b/scripts/gpu-probe.mjs index e9ee370..6b7de49 100644 --- a/scripts/gpu-probe.mjs +++ b/scripts/gpu-probe.mjs @@ -34,7 +34,7 @@ export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid function scheduleRespawn(entry) { if (entry.respawned) return; entry.respawned = true; - if (current === entry) current = null; + current = null; setTimeout(launch, Math.max(0, lastSpawnAt + intervalMs - Date.now())).unref(); } @@ -60,7 +60,7 @@ export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid const lines = pending.split("\n"); pending = lines.pop(); for (const line of lines) { - const sample = parseSample(line.trim()); + const sample = parseSample(line); if (sample) { lastSampleAt = Date.now(); onSample(sample); @@ -72,7 +72,7 @@ export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid } const watchdog = setInterval(() => { - if (stopped || current === null || Date.now() - lastSampleAt <= staleIntervals * intervalMs) return; + if (current === null || Date.now() - lastSampleAt <= staleIntervals * intervalMs) return; const hung = current; scheduleRespawn(hung); hung.child.kill(); diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index 840c95a..7f1ac4b 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -270,8 +270,25 @@ test("own VRAM: the next request right after a job is admitted while the child's return "second"; }); assert.equal(second, "second"); + r.feed({ ...CLEAR, freeMiB: 5000 }); + assert.equal(r.gate.status().inUse, false, "the baseline survives back-to-back jobs: 5000 is not recovery from a 14000 start"); r.feed(CLEAR); - assert.equal(r.gate.status().inUse, false, "the baseline survives back-to-back jobs, so recovery is judged against the first"); + assert.equal(r.gate.status().inUse, false); + r.feed({ ...CLEAR, freeMiB: 5000 }); + assert.equal(r.gate.status().inUse, true, "recovered, so low VRAM is the household's again"); +}); + +test("slot: releasing twice does not restart the drain", () => { + const r = freeRig(); + const slot = r.gate.acquire(); + r.feed(OWN); + slot.release(); + r.feed(OWN); + r.feed(OWN); + r.feed(OWN); + slot.release(); // a second release is nothing: it must not give the drain a fresh cap + r.feed(OWN); + assert.equal(r.gate.status().inUse, true, "60 s after the one release, the drain cap has passed"); }); test("own VRAM: low VRAM that outlives the drain is the household's, and refuses", async () => { @@ -362,6 +379,8 @@ test("env overrides: a malformed value refuses to start and names the variable, ); } } - assert.throws(() => gpuTenancyOptionsFromEnv({ VILI_GPU_SAMPLE_MS: "0" }), /VILI_GPU_SAMPLE_MS/, "a zero interval would spin the probe"); - assert.doesNotThrow(() => gpuTenancyOptionsFromEnv({ VILI_GPU_CLEAR_HOLD_MS: "0" })); + assert.throws(() => gpuTenancyOptionsFromEnv({ VILI_GPU_SAMPLE_MS: "0" }), { message: "VILI_GPU_SAMPLE_MS must be a finite positive number" }); + assert.throws(() => gpuTenancyOptionsFromEnv({ VILI_GPU_MIN_FREE_MIB: "x" }), { message: "VILI_GPU_MIN_FREE_MIB must be a finite non-negative number" }); + assert.deepEqual(gpuTenancyOptionsFromEnv({ VILI_GPU_CLEAR_HOLD_MS: "0", VILI_GPU_MIN_FREE_MIB: "0", VILI_GPU_UTIL3D_BUSY_PCT: "0" }), + { clearHoldMs: 0, minFreeMiB: 0, util3dBusyPct: 0 }, "zero is a valid hold, minimum and threshold"); }); From b489d2885d398c8e28dcff26d36f7e50df0a7149 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 01:40:54 +0200 Subject: [PATCH 14/26] Map: GPU gate becomes a household tray toggle (r2 rationale and probes) Co-Authored-By: Claude Opus 5.5 --- docs/motion-pipeline-map.md | 122 +++++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 35 deletions(-) diff --git a/docs/motion-pipeline-map.md b/docs/motion-pipeline-map.md index f79792b..c7ec160 100644 --- a/docs/motion-pipeline-map.md +++ b/docs/motion-pipeline-map.md @@ -7,8 +7,33 @@ cuts not yet mapped. The target's rationale is `docs/motion-pipeline-target.md`. ## Body facts +### Raven tray-toggle probes, 2026-10-22 23:30 to 23:36 UTC + +Run read-only from Starfire over `ssh raven` (session 0, elevated), one +PowerShell 5.1 script copied to `C:\Users\Public` and deleted in the same +command. Nothing on Raven was changed: no task edits, no restarts, no GPU jobs. +They inform cut spec `motion-pipeline:cut_spec:cut-raven-gpu-tenancy-gate.r2`, +which replaces detection with the household's toggle +(`motion-pipeline:ruling:raven-gpu-tray-toggle`). + +| Probe | Result | +|---|---| +| Who the household is on Raven | One account. `query user`: `madman's lullaby`, console, session 1, Active since 2026-10-18. The `\GameCult\Vili` task runs as that same user (`LogonType Interactive`, `RunLevel Limited`), so a tray process at that user's logon is in the same session and the same user as the daemon, with no admin rights. | +| Daemon listen address | `node.exe ... vili-daemon.mjs --host 0.0.0.0 --port 8824 --state-root E:\Projects\Vili\.vili`, pid 33776, session 1, at detached head `a2b9983`. Listens on `0.0.0.0:8824`, so the WireGuard peers can reach every route; a write route must check the caller itself. `GET http://127.0.0.1:8824/operator-state` from Raven answers in 3 ms. | +| Live task action versus the installer | The live `\GameCult\Vili` action passes `-IdunnRudpHealth 10.77.0.2:17870 -IdunnDaemon vili -IdunnHealthContract ... -IdunnHealthIntervalSeconds 15` to `start-vili-daemon.ps1`; `scripts/install-vili-task.ps1` does not. Rerunning that installer would rewrite the live task, so the tray gets its own installer. | +| Tray substrate | `powershell.exe` 5.1.26100, apartment `STA` by default; `System.Windows.Forms` and `System.Drawing` load (`v4.0.30319`, .NET Framework release 534296). Execution policy is Undefined at every scope (the task launcher passes `-ExecutionPolicy Bypass`). `scripts/run-hidden-powershell.vbs` already starts a hidden PowerShell with no console flash. No new dependency is needed. | +| Tray overflow | `HKCU:\Control Panel\NotifyIconSettings` exists with 47 entries: Windows 11 records each notify icon there and puts new ones in the overflow until the user promotes it. A fresh tray icon will not be on the taskbar until the household drags it there once (or turns it on under Settings, Personalization, Taskbar, Other system tray icons). | +| Startup folder | `C:\Users\Madman's Lullaby\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup`. Not used: from SSH (session 0) nothing in it can be started in session 1, while `Start-ScheduledTask` on an Interactive task can. | +| Scheduled tasks under `\GameCult\` | `Backup-Yggdrasil-Pull`, `ComfyUI`, `Vili`, all Ready. ComfyUI is a second possible heavy GPU consumer on Raven; the toggle governs Vili only. | +| Host free VRAM | `C:\WINDOWS\system32\nvidia-smi.exe --query-gpu=memory.free --format=csv,noheader,nounits`: 159 ms from a cold PowerShell, exit 0, free 5001 MiB at 23:3x UTC (the household or another consumer held about 11 GiB). No `nvidia-smi.exe` process was left running. | +| Vili's store | `E:\Projects\Vili\.vili\vili.service.cc`, 11810 bytes. Newest job file `vili-1792710792764.json`, written 23:20:12 UTC. The live gate (`a2b9983`) read `inUse true, probeOk true`. | +| cultcache-ts on Raven | `E:\Projects\CultLib` at `b9497d35`, package `0.14.1`; its build carries `pullAllBackingStores`. `SingleFileMessagePackBackingStore.push` reads the file, replaces one `(type, key)` entry and writes all, inside a write queue that belongs to one store instance. | + ### Raven GPU tenancy probes, 2026-10-22 20:15 to 20:23 UTC +These informed r1's detection gate, which r2 deletes. The session, principal and +`nvidia-smi` rows still hold. + Run read-only from Starfire over `ssh -o HostName=192.168.178.165 raven` (alias in `~/.ssh/config`; LAN address per `gamecult-ops/inventory.md`), as `powershell -NoProfile -NonInteractive -EncodedCommand`. Nothing on Raven was @@ -21,56 +46,83 @@ category (compositor, WSL family, or "other"). | Session of the Vili daemon | `node.exe ... vili-daemon.mjs --host 0.0.0.0 --port 8824` runs in **session 1**, the console session of `madman's lullaby` (`query user`: console, ID 1, Active). It is the interactive desktop session, not session 0. | | Vili task principal | `\GameCult\Vili`: `LogonType Interactive`, `RunLevel Limited`, boot trigger (`MSFT_TaskBootTrigger`), last run 2026-10-22 21:18:55 local, result 0. Children the daemon spawns inherit session 1. | | SSH context | Windows `sshd` runs commands in **session 0**, elevated (`IsInRole(Administrator)` true). `gamecult-ops/runbooks/idunn-host-raven.md` already records that sshd cannot start anything in the user's session without a scheduled-task hop. | -| `SHQueryUserNotificationState` from session 0 | `hr=0`, state `2` (`QUNS_BUSY`) on every call, with the GPU idle (3D engine total 0.01 to 0.02 %). Session 0 has no shell, so this value says nothing about the user's desktop. The call must run in session 1, which is where the daemon and its children are. It was **not** observed from session 1: doing that without a task edit is impossible from SSH. | -| `nvidia-smi.exe` | Present at `C:\WINDOWS\system32\nvidia-smi.exe`. `--query-gpu=memory.total,memory.used,memory.free,utilization.gpu`: `16380, 1592, 14516, 2` MiB/%. 63 ms cold, about 49 ms per call (10 calls 491 ms). Per-process `--query-compute-apps` returns `[N/A]` memory under WDDM. | -| `\GPU Engine(*engtype_3D)\Utilization Percentage` | Readable from session 0 as admin; 115 instances. `Get-Counter -SampleInterval 1 -MaxSamples 1` takes 1.08 s wall, 16 ms CPU warm, 328 ms CPU on first use in a process. Summed with `Measure-Object` it needs no instance names. Not tried as a Limited token in session 1. | -| `\GPU Adapter Memory(*)\Dedicated Usage` | Readable; 1.0 s wall; 1597 MiB on the RTX 4060 Ti, 0 on the Parsec virtual adapter. | -| `\GPU Process Memory(*)\Dedicated Usage` | Readable; 1.0 s wall. Two instances above 50 MiB: the desktop compositor (1643 MiB, session 1) and one session-0 process outside the WSL family (1405 MiB). Per-process figures overlap: they sum above the adapter total. | -| WSL attribution | The Kimodo worker container was resident (`/health` ok, uptime 3454 s, 6 generations) while `vmmemWSL` (and `vmwp`) had **no** `GPU Process Memory` instance. Host counters do not attribute WSL CUDA memory to a nameable host process. WSL `nvidia-smi` reported the same `1592 MiB used, 14516 MiB free` as the host. | -| PowerShell cost | `powershell.exe` 5.1 cold start 266 ms CPU, `Add-Type` for the shell32 call 78 ms CPU, first counter read 328 ms CPU: about 0.7 s CPU per cold spawn. A warm loop iteration (QUNS + `nvidia-smi` + one 3D counter read) is about 1.1 s wall and 16 ms of its own CPU. | +| `SHQueryUserNotificationState` from session 0 | `hr=0`, state `2` (`QUNS_BUSY`) on every call, with the GPU idle. Session 0 has no shell, so this value says nothing about the user's desktop. | +| `nvidia-smi.exe` | Present at `C:\WINDOWS\system32\nvidia-smi.exe`. `--query-gpu=memory.total,memory.used,memory.free,utilization.gpu`: `16380, 1592, 14516, 2` MiB/%. 63 ms cold, about 49 ms per call. Per-process `--query-compute-apps` returns `[N/A]` memory under WDDM. | +| `\GPU Engine(*engtype_3D)\Utilization Percentage` | Readable from session 0 as admin; 115 instances; `Get-Counter` 1.08 s wall. | +| `\GPU Process Memory(*)\Dedicated Usage` | Per-process figures overlap and sum above the adapter total. | +| WSL attribution | Host counters do not attribute WSL CUDA memory to a nameable host process. Host `nvidia-smi.exe` does see a WSL container's allocation: cut report h1 measured free falling 14292 to 1866 MiB under a 12 GiB holder. | | Adapters | `NVIDIA GeForce RTX 4060 Ti` driver 32.0.16.1088, 16 GiB; `Parsec Virtual Display Adapter`. | -What follows from these, and what does not: +### What the detection gate taught (r1, h1, h2, s1, s2) -- The daemon's own session sees the user's desktop, so the tenancy probe runs as - a child of the daemon. No fork about where it runs: the scheduled task already - puts it in the right session. -- Free VRAM must come from the host (`nvidia-smi.exe`), not from torch in WSL: - Soul's s1 probe on cut `vili-encoder-placement` showed `mem_get_info` inside a - container blind to another container's 12 GiB. Whether host `nvidia-smi` sees - a WSL container's allocation is **unverified** (the resident worker's share - could not be separated from the compositor's); the gate cut checks it first. -- The 3D-engine signal cannot be used to tell Vili's own work from the - household's by process, and need not be: Kimodo runs on CUDA (compute - engines), games on the 3D engine. Summing only `engtype_3D` leaves Vili out. -- A long-lived probe child costs about 1 ms of CPU per second at a 15 s period; - a fresh PowerShell per sample would cost about 45 ms per second. Neither - loads the GPU. +- Host free VRAM cannot tell Vili's encoder child from a household load: the + child sizes itself to the VRAM that was free (s1 `self-tenancy-refusal`). + Discounting Vili's own VRAM during a job blinds the gate to a household load + that starts mid-job (s2 `hold-blinds-household-vram`). +- Live, the probe child went silent during 2 of 5 of Vili's own encoder jobs and + the gate stayed stale after the job (s2 `own-load-starves-probe`; Raven had + 770 MB of host RAM available at one stall). Every repair added a watchdog, a + drain or a baseline, and each brought its own finding. +- After a job ends, host free VRAM is back at 14.6 to 15.2 GiB at the next + 2-second sample (s2), so one `nvidia-smi` read before a job, with no job + holding the slot, sees the card as the household left it. ## Model page | Kind | Named by | Over time | Decided by | |---|---|---|---| -| GPU tenancy state (`gpu` in `gamecult.vili.operator_state.v1`) | the daemon, one per host | rewritten every 5 s with the operator state; holds `inUse`, `since`, `checkedAt`, `probeOk` and nothing else | the daemon's tenancy gate, from host samples; never persisted as truth, rebuilt from samples at start | +| GPU permission (`gamecult.vili.gpu_permission.v1` in `.vili/vili.service.cc`, key `raven`) | the daemon, one per host | written only when the household changes it, before the change is acknowledged; read once at daemon start; absent means not allowed | the household, through the tray, through the daemon's loopback-only `POST /gpu-permission`; the daemon is its one writer | +| GPU status (`gpu` in `gamecult.vili.operator_state.v1`) | the daemon | rewritten every 5 s with the operator state; `{ allowed, changedAt, jobRunning, lastRefusal }` | derived from the permission, the slot and the last refusal; display only, never read back | | Motion job (`.vili/jobs/.json`) | `vili-` | written once when a job ends | the daemon; a job refused at admission writes none | | Text embedding (`.vili/embeddings/text-embeddings.cc`) | `sha256(identity, text)` | append on miss | the worker's cache | ## Rationale -- **Refuse, do not queue (recommended, pending the operator).** `/motion/generate` - is synchronous today. Holding a request open for as long as a game runs, or - adding a persisted job queue, are bigger machines than the one consumer needs: - RPS clip generation is a script an agent runs, and it can retry a typed - `gpu-busy`. A queue belongs with the authoring UX's takes, when that target - exists (`motion-pipeline:follow_up:vili-authoring-ux-target-2`). -- **Fail closed.** If the probe child dies or stops answering, the gate reports - in use. A wrongly deferred clip costs minutes; a wrongly admitted encoder child - pages a game's VRAM and thrashes Raven. +- **The household decides; nothing detects.** The operator replaced detection + with a tray toggle (`motion-pipeline:ruling:raven-gpu-tray-toggle`). The + detection gate's findings were the cost of guessing intent from counters; a + yes from the person at the desk needs no guess, no hysteresis and no probe. +- **The daemon is the one owner of the permission; the tray is a lowering.** + The tray reads and sets the state only through the daemon's HTTP route, so + admission and the stored value cannot disagree and the tray holds no state of + its own. The alternative, the tray writing a CultCache document the daemon + reads, would give the document two writers on two clocks and make the daemon + poll a file to learn its own admission rule. +- **Off on a fresh install, and off when the document is missing.** The safe + default for the household: Vili does nothing on Raven's GPU until someone at + the desk says yes. A stored yes survives restarts and reboots. +- **Loopback only for the write.** The daemon listens on `0.0.0.0`; the + household's switch must not be flippable from a WireGuard peer. A request + from a non-loopback address is refused 403. Agents read the state through + `GET /gpu-permission` or `/operator-state` and never set it. +- **One cache instance for the daemon.** The 5-second surface writer built a + new store per call. Two store instances writing one file each read, modify and + write in their own queue, so a permission write could be lost under a + concurrent surface write. One module-level cache serializes every write to + `vili.service.cc`. +- **One slot, one fresh VRAM read, fail closed.** h2's slot stays: one job at a + time, the rest refused at once with source `slot`. The slot is taken + synchronously before the VRAM read, so two requests cannot both pass it. A + failed or unparseable read refuses with source `vram`, so a job never starts + onto a full card on a guess. The permission is checked again after the read. +- **Refuse, do not queue** (`motion-pipeline:ruling:gpu-defer-refuse-typed`). + `/motion/generate` is synchronous; the consumer retries a typed `gpu-busy` + after `retry-after`. A queue belongs with the authoring UX's takes + (`motion-pipeline:follow_up:vili-authoring-ux-target-2`). +- **A logon task, not the Startup folder.** `Start-ScheduledTask` on an + Interactive task puts the tray in the household's session from SSH at deploy + time; a Startup folder entry would wait for the next logon. The task pattern + matches `\GameCult\Vili`. +- **The privacy rule is moot.** "Never record program names" protected the + household from a probe that watched its programs. With no probe, nothing in + Vili observes the household's programs; the only household fact Vili holds is + the yes or no the household chose. - **The child's own VRAM check is placement, not tenancy.** `plan_placement` - still sizes accelerate's `max_memory` from torch's view and can still refuse; - it answers "can the encoder be placed", not "may heavy work start". + still sizes accelerate's `max_memory` from torch's view and can still refuse + (source `placement`); it answers "can the encoder be placed", not "may heavy + work start". ## Cuts not yet mapped - RPS beats through Vili (`motion-pipeline:follow_up:rps-clips-from-vili`), after - the gate and `vili-fbx-export`. + the gate and `vili-fbx-export`. They wait on the household's yes on Raven. From ed07c5a4af6a6518adc10f6c562c8905cf55cc9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 01:42:33 +0200 Subject: [PATCH 15/26] Delete the GPU detection gate: probe child, supervisor, thresholds and their tests The household's use is no longer detected. gpu-probe.mjs, the PowerShell probe child, the hysteresis/3D/full-screen/own-VRAM rules, three env variables and the README paragraph go; the gate keeps only the single slot and the placement refusal until the permission lands in the next commit. Co-Authored-By: Claude Opus 5.5 --- README.md | 22 +- package.json | 2 +- scripts/gpu-probe.mjs | 84 ------- scripts/gpu-tenancy-probe.ps1 | 24 -- scripts/gpu-tenancy.mjs | 138 ++---------- scripts/test-gpu-gate-http.mjs | 57 +---- scripts/test-gpu-probe.mjs | 166 -------------- scripts/test-gpu-tenancy.mjs | 387 ++------------------------------- scripts/vili-daemon.mjs | 14 +- 9 files changed, 52 insertions(+), 842 deletions(-) delete mode 100644 scripts/gpu-probe.mjs delete mode 100644 scripts/gpu-tenancy-probe.ps1 delete mode 100644 scripts/test-gpu-probe.mjs diff --git a/README.md b/README.md index c20675a..0a52c1c 100644 --- a/README.md +++ b/README.md @@ -166,25 +166,9 @@ it has no CPU fallback, and hits never touch it. Each hashes) and the worker identity. `GET /health` shows the identity and the stored count. -GPU tenancy. Raven's GPU is the household's first. The daemon (Windows, in the -console session) samples, every 15 seconds, the full-screen state, the 3D engine's use and -the host's free VRAM, as numbers only, through one hidden PowerShell child -(`scripts/gpu-tenancy-probe.ps1`, supervised by `scripts/gpu-probe.mjs`: a child that exits or -sends nothing for 3 intervals is replaced). The daemon holds one GPU slot, and a -`POST /motion/generate` is judged when it reaches the slot. It is refused with HTTP 503, -`retry-after: 60` and `{ ok: false, code: "gpu-busy", source: "admission", gpu }` while the -household is using the GPU, or `{ ok: false, code: "gpu-busy", source: "slot" }` while another job -holds the slot; nothing queues, and the consumer owns the retry. The GPU counts as in use at -boot, on any full-screen sample, on free VRAM under 10 GiB, or on 3D use above 20% for two -samples in a row, and it counts as free only after every sample for 3 minutes is clear. Vili's own -job takes most of the VRAM, so while a job holds the slot, and until the free VRAM has recovered to -within 1 GiB of what it was before the job (at most 60 seconds), low free VRAM is not counted -against the household; full-screen and 3D use always count. A job already running finishes; the -gate never stops work. `operator_state.gpu` is `{ inUse, since, checkedAt, probeOk }`; it never -names a program, and a probe that goes silent, or finds no 3D engine counters, reads as in use -with `probeOk` false. `VILI_GPU_SAMPLE_MS`, `VILI_GPU_CLEAR_HOLD_MS`, `VILI_GPU_MIN_FREE_MIB` and -`VILI_GPU_UTIL3D_BUSY_PCT` override the thresholds; a value that is not a finite number refuses -to start the daemon. `npm test` runs the gate's tests. +GPU tenancy. The daemon holds one GPU slot; a `POST /motion/generate` while another job holds it +is refused with HTTP 503, `retry-after: 60` and `{ ok: false, code: "gpu-busy", source: "slot" }`. +A job already running finishes; the gate never stops work. `npm test` runs the gate's tests. Measured on Raven on `2026-10-22`, for a 2 second clip at 20 diffusion steps: the worker is healthy 29 seconds after `docker rm` (was 179) and rests at 2.3 GiB diff --git a/package.json b/package.json index fc6d3cb..4164e53 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node scripts/vili-daemon.mjs", "health": "node scripts/vili-daemon.mjs --health", - "test": "node --test scripts/test-gpu-tenancy.mjs scripts/test-gpu-probe.mjs", + "test": "node --test scripts/test-gpu-tenancy.mjs", "test:http": "node --test scripts/test-gpu-gate-http.mjs", "smoke": "node scripts/smoke-vili.mjs", "provider": "node scripts/vili-daemon.mjs --print-provider-advertisement" diff --git a/scripts/gpu-probe.mjs b/scripts/gpu-probe.mjs deleted file mode 100644 index 6b7de49..0000000 --- a/scripts/gpu-probe.mjs +++ /dev/null @@ -1,84 +0,0 @@ -// The sample source for the GPU tenancy gate: one long-lived hidden PowerShell child in the daemon's -// own session. It decides nothing; it only turns the child's stdout into samples and keeps the child -// alive. - -// The 3D-engine counter the probe reads. A wrong path reads as zero without an error, so the path is -// one constant, pinned by a test, and the probe reports how many engine counters it resolved. -export const UTIL3D_COUNTER_PATH = String.raw`\GPU Engine(*engtype_3D)\Utilization Percentage`; - -// A probe line is JSON; only the four finite numbers survive, so nothing else can reach the gate. -export function parseSample(line) { - let value; - try { - value = JSON.parse(line); - } catch { - return null; - } - if (value === null || typeof value !== "object") return null; - const { quns, freeMiB, util3d, engines } = value; - for (const field of [quns, freeMiB, util3d, engines]) { - if (typeof field !== "number" || !Number.isFinite(field)) return null; - } - return { quns, freeMiB, util3d, engines }; -} - -// The child exits with the daemon (the script watches the parent pid). If it exits on its own it is -// respawned at most once per interval. A child that stays alive but sends no sample for staleIntervals -// intervals is hung: it is killed and respawned the same way. -export function startProbe({ spawn, scriptPath, intervalMs, onSample, parentPid = process.pid, staleIntervals = 3 }) { - let stopped = false; - let lastSpawnAt = 0; - let lastSampleAt = 0; - let current = null; - - function scheduleRespawn(entry) { - if (entry.respawned) return; - entry.respawned = true; - current = null; - setTimeout(launch, Math.max(0, lastSpawnAt + intervalMs - Date.now())).unref(); - } - - function launch() { - if (stopped) return; - lastSpawnAt = Date.now(); - lastSampleAt = lastSpawnAt; - const child = spawn("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", "Bypass", - "-File", scriptPath, - "-ParentPid", String(parentPid), - "-IntervalMs", String(intervalMs), - "-Util3dPath", UTIL3D_COUNTER_PATH, - ], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }); - const entry = { child, respawned: false }; - current = entry; - let pending = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - pending += chunk; - const lines = pending.split("\n"); - pending = lines.pop(); - for (const line of lines) { - const sample = parseSample(line); - if (sample) { - lastSampleAt = Date.now(); - onSample(sample); - } - } - }); - child.on("exit", () => scheduleRespawn(entry)); - child.on("error", () => scheduleRespawn(entry)); - } - - const watchdog = setInterval(() => { - if (current === null || Date.now() - lastSampleAt <= staleIntervals * intervalMs) return; - const hung = current; - scheduleRespawn(hung); - hung.child.kill(); - }, intervalMs); - watchdog.unref(); - - launch(); - return { stop: () => { stopped = true; clearInterval(watchdog); } }; -} diff --git a/scripts/gpu-tenancy-probe.ps1 b/scripts/gpu-tenancy-probe.ps1 deleted file mode 100644 index e172b37..0000000 --- a/scripts/gpu-tenancy-probe.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -# One sample line per interval: {"quns":n,"freeMiB":n,"util3d":n,"engines":n}. Numbers only; no process -# identity is read. engines is how many 3D-engine counters the path resolved: zero means the path is -# wrong, and the gate treats that as a probe fault. -param([int]$ParentPid, [int]$IntervalMs = 15000, [string]$Util3dPath) -$ErrorActionPreference = 'Stop' -Add-Type -Namespace Vili -Name Shell -MemberDefinition '[DllImport("shell32.dll")] public static extern int SHQueryUserNotificationState(out int state);' -while ($true) { - $clock = [Diagnostics.Stopwatch]::StartNew() - if (-not (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue)) { exit } - try { - $quns = 0 - if ([Vili.Shell]::SHQueryUserNotificationState([ref]$quns) -ne 0) { throw 'quns' } - $smi = @(nvidia-smi.exe --query-gpu=memory.free --format=csv,noheader,nounits) - if ($LASTEXITCODE -ne 0) { throw 'smi' } - $free = $smi[0] - $counters = @((Get-Counter $Util3dPath -SampleInterval 1 -MaxSamples 1).CounterSamples) - $sum = $counters | Measure-Object -Property CookedValue -Sum - $line = [ordered]@{ quns = [int]$quns; freeMiB = [int]$free; util3d = [math]::Min(100, [math]::Round([double]$sum.Sum, 1)); engines = $counters.Count } | ConvertTo-Json -Compress - [Console]::Out.WriteLine($line) - [Console]::Out.Flush() - } catch { } - $rest = $IntervalMs - $clock.ElapsedMilliseconds - if ($rest -gt 0) { Start-Sleep -Milliseconds $rest } -} diff --git a/scripts/gpu-tenancy.mjs b/scripts/gpu-tenancy.mjs index 8f8c0ce..b6156ff 100644 --- a/scripts/gpu-tenancy.mjs +++ b/scripts/gpu-tenancy.mjs @@ -1,110 +1,22 @@ -// GPU tenancy gate: the one owner of "may heavy GPU work start now". It reads numbers only -// (full-screen state, 3D-engine use, host free VRAM), keeps a yes/no and a timestamp, and never -// learns which program is running. It refuses new work; it never stops work. -// -// The daemon holds one GPU slot. A job takes the slot through acquire() and the gate judges at that -// moment: the household signals say no, or another job holds the slot, and the answer is a typed -// refusal. Nothing waits. While Vili's own job holds the slot, and until its VRAM has drained after -// the slot frees, the free-VRAM signal is not the household's: the encoder child sizes itself to the -// VRAM that was free, so no VRAM sample separates Vili from a game. Full-screen and 3D-engine use -// always count. +// GPU tenancy gate. The detection of the household's use is deleted; this file holds the one GPU +// slot only, until the household's permission lands. It refuses new work and never stops work. export const RETRY_AFTER_SECONDS = 60; -const QUNS_BUSY = 2; -const QUNS_RUNNING_D3D_FULL_SCREEN = 3; -export function createGpuTenancy({ - now, - sampleIntervalMs = 15000, - clearHoldMs = 180000, - minFreeMiB = 10240, - util3dBusyPct = 20, - staleAfterMs = 3 * sampleIntervalMs, - drainToleranceMiB = 1024, - drainCapMs = 4 * sampleIntervalMs, -}) { - const startedAt = now(); - let inUse = true; - let since = startedAt; - let checkedAt = null; - let lastSeenAt = startedAt; - let clearSince = null; - let previousUtil3d = null; - let lastFreeMiB = null; +export function createGpuTenancy({ minFreeMiB = 10240 } = {}) { let held = false; - let baselineFreeMiB = null; - let drainingSince = null; - - const staleSince = () => (now() - lastSeenAt > staleAfterMs ? lastSeenAt + staleAfterMs : null); - - function observe(sample) { - // A probe that found no 3D engine counters is blind to the signal: a probe fault, not a clear - // sample. The sample is dropped, the gate goes stale and fails closed. - if (!(sample.engines >= 1)) return; - const t = now(); - const staleAt = staleSince(); - if (staleAt !== null) { - if (!inUse) { - inUse = true; - since = staleAt; - } - clearSince = null; - previousUtil3d = null; - } - if (drainingSince !== null && (sample.freeMiB >= baselineFreeMiB - drainToleranceMiB || t - drainingSince >= drainCapMs)) { - drainingSince = null; - } - const ownVram = held || drainingSince !== null; - const sustained3d = sample.util3d > util3dBusyPct && previousUtil3d !== null && previousUtil3d > util3dBusyPct; - const busy = sample.quns === QUNS_BUSY - || sample.quns === QUNS_RUNNING_D3D_FULL_SCREEN - || (!ownVram && sample.freeMiB < minFreeMiB) - || sustained3d; - previousUtil3d = sample.util3d; - lastFreeMiB = sample.freeMiB; - lastSeenAt = t; - checkedAt = t; - if (busy) { - clearSince = null; - if (!inUse) { - inUse = true; - since = t; - } - return; - } - if (clearSince === null) clearSince = t; - if (inUse && t - clearSince >= clearHoldMs) { - inUse = false; - since = t; - } - } - - function status() { - const staleAt = staleSince(); - if (staleAt !== null) { - return { inUse: true, since: inUse ? since : staleAt, checkedAt, probeOk: false }; - } - return { inUse, since, checkedAt, probeOk: true }; - } - - // Judges once and either takes the slot or answers the typed refusal. The household's answer comes - // first: it is the one a consumer can act on. - function acquire() { - const gpu = status(); - if (gpu.inUse) return { refusal: { ok: false, code: "gpu-busy", source: "admission", gpu } }; - if (held) return { refusal: { ok: false, code: "gpu-busy", source: "slot" } }; - held = true; - if (drainingSince === null) baselineFreeMiB = lastFreeMiB; - return { - release() { - if (!held) return; - held = false; - drainingSince = now(); - }, - }; - } - - return { observe, status, acquire }; + return { + status: () => ({ jobRunning: held, minFreeMiB }), + acquire() { + if (held) return { refusal: { ok: false, code: "gpu-busy", source: "slot" } }; + held = true; + return { + release() { + held = false; + }, + }; + }, + }; } // Takes the slot, runs the job to its end and returns its result, or returns the typed refusal @@ -128,25 +40,15 @@ export function placementRefusal(workerBody, jobId) { return { ok: false, code: "gpu-busy", source: "placement", jobId }; } -const ENV_OPTIONS = [ - ["VILI_GPU_SAMPLE_MS", "sampleIntervalMs", true], - ["VILI_GPU_CLEAR_HOLD_MS", "clearHoldMs", false], - ["VILI_GPU_MIN_FREE_MIB", "minFreeMiB", false], - ["VILI_GPU_UTIL3D_BUSY_PCT", "util3dBusyPct", false], -]; - -// Operator overrides. A malformed value would silently defeat a comparison (NaN is never below +// Operator override. A malformed value would silently defeat a comparison (NaN is never below // anything), so it refuses to start instead, naming the variable and never its value. export function gpuTenancyOptionsFromEnv(env) { const options = {}; - for (const [name, key, positive] of ENV_OPTIONS) { - const raw = env[name]; - if (raw === undefined) continue; + const raw = env.VILI_GPU_MIN_FREE_MIB; + if (raw !== undefined) { const value = raw.trim() === "" ? Number.NaN : Number(raw); - if (!Number.isFinite(value) || value < 0 || (positive && value === 0)) { - throw new Error(`${name} must be a finite ${positive ? "positive" : "non-negative"} number`); - } - options[key] = value; + if (!Number.isFinite(value) || value < 0) throw new Error("VILI_GPU_MIN_FREE_MIB must be a finite non-negative number"); + options.minFreeMiB = value; } return options; } diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 3414c04..4d1b3b0 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -1,9 +1,8 @@ -// Boots the real daemon and observes the gate where it is decided: the HTTP edge. A fresh daemon is -// in use until a clear hold passes, so a new job is refused whatever the host is doing. Needs the +// Boots the real daemon and observes the gate where it is decided: the HTTP edge. Needs the // daemon's own dependencies (ws, cultcache-ts), so it runs on a machine that has them. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -12,58 +11,6 @@ import { fileURLToPath } from "node:url"; const daemonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "vili-daemon.mjs"); const port = 18000 + Math.floor(Math.random() * 1000); -async function bootDaemon(stateRoot) { - const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(port), "--state-root", stateRoot], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - let output = ""; - child.stdout.on("data", (chunk) => { output += chunk; }); - child.stderr.on("data", (chunk) => { output += chunk; }); - const deadline = Date.now() + 30000; - while (!output.includes("Vili listening")) { - if (child.exitCode !== null || Date.now() > deadline) throw new Error(`daemon did not start: ${output.slice(-500)}`); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return child; -} - -const post = (body) => fetch(`http://127.0.0.1:${port}/motion/generate`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), -}); - -test("a new job is refused 503 gpu-busy with retry-after, creating nothing; validation stays 400", async () => { - const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-")); - const daemon = await bootDaemon(stateRoot); - try { - const invalid = await post({}); - assert.equal(invalid.status, 400, "request-shape errors come ahead of the gate"); - - const refused = await post({ prompt: "a person waves hello, never seen before" }); - assert.equal(refused.status, 503); - assert.equal(refused.headers.get("retry-after"), "60"); - const body = await refused.json(); - assert.equal(body.ok, false); - assert.equal(body.code, "gpu-busy"); - assert.equal(body.source, "admission"); - assert.equal(body.gpu.inUse, true); - assert.deepEqual(Object.keys(body.gpu).sort(), ["checkedAt", "inUse", "probeOk", "since"]); - assert.equal(body.jobId, undefined); - - assert.deepEqual(await readdir(path.join(stateRoot, "jobs")), [], "no job file"); - assert.equal((await readdir(stateRoot)).includes("artifacts"), false, "no artifact directory"); - - const state = await (await fetch(`http://127.0.0.1:${port}/operator-state`)).json(); - assert.deepEqual(Object.keys(state.gpu).sort(), ["checkedAt", "inUse", "probeOk", "since"]); - assert.equal(state.gpu.inUse, true); - } finally { - daemon.kill(); - await rm(stateRoot, { recursive: true, force: true }); - } -}); - test("a malformed GPU override refuses to start and names the variable, not its value", async () => { const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-env-")); const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(port + 1), "--state-root", stateRoot], { diff --git a/scripts/test-gpu-probe.mjs b/scripts/test-gpu-probe.mjs deleted file mode 100644 index 4de6ac8..0000000 --- a/scripts/test-gpu-probe.mjs +++ /dev/null @@ -1,166 +0,0 @@ -import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; -import { UTIL3D_COUNTER_PATH, parseSample, startProbe } from "./gpu-probe.mjs"; -import { createGpuTenancy } from "./gpu-tenancy.mjs"; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -test("no program name canary", () => { - const canary = "canary-vili-7f3a"; - const line = JSON.stringify({ quns: 3, freeMiB: 2048, util3d: 55.5, engines: 8, name: canary, process: canary, instance: canary }); - const sample = parseSample(line); - assert.deepEqual(sample, { quns: 3, freeMiB: 2048, util3d: 55.5, engines: 8 }); - assert.deepEqual(Object.keys(sample).sort(), ["engines", "freeMiB", "quns", "util3d"]); - const gate = createGpuTenancy({ now: () => 1_000_000 }); - gate.observe(sample); - assert.equal(JSON.stringify(gate.status()).includes(canary), false); -}); - -test("parseSample drops anything that is not four finite numbers", () => { - const ok = { quns: 1, freeMiB: 1, util3d: 1, engines: 1 }; - assert.deepEqual(parseSample(JSON.stringify(ok)), ok); - assert.equal(parseSample("not json"), null); - assert.equal(parseSample("null"), null); - assert.equal(parseSample("[1,2,3]"), null); - assert.equal(parseSample(""), null); - for (const field of Object.keys(ok)) { - const missing = { ...ok }; - delete missing[field]; - assert.equal(parseSample(JSON.stringify(missing)), null, `${field} missing`); - assert.equal(parseSample(JSON.stringify({ ...ok, [field]: "1" })), null, `${field} as a string`); - } - assert.equal(parseSample('{"quns":1,"freeMiB":1,"util3d":1e999,"engines":1}'), null); -}); - -test("the 3D counter path is the one constant, and the script has no copy of it", async () => { - assert.equal(UTIL3D_COUNTER_PATH, "\\GPU Engine(*engtype_3D)\\Utilization Percentage"); - const script = await readFile(new URL("./gpu-tenancy-probe.ps1", import.meta.url), "utf8"); - assert.equal(/GPU Engine|engtype/.test(script.replace(/^#.*$/gm, "")), false, "the path reaches the script as -Util3dPath only"); - assert.match(script, /Get-Counter \$Util3dPath/); - assert.match(script, /engines = \$counters\.Count/); -}); - -test("a probe that resolved no 3D counters keeps the gate failing closed", () => { - let t = 1_000_000; - const gate = createGpuTenancy({ now: () => t }); - const line = (engines) => JSON.stringify({ quns: 1, freeMiB: 14000, util3d: 0, engines }); - for (let i = 0; i < 20; i += 1) { - t += 15_000; - gate.observe(parseSample(line(0))); - } - assert.deepEqual(gate.status(), { inUse: true, since: 1_000_000, checkedAt: null, probeOk: false }); - for (let i = 0; i < 13; i += 1) { - t += 15_000; - gate.observe(parseSample(line(8))); - } - assert.equal(gate.status().inUse, false, "the same clear samples with a resolved counter do clear the gate"); - assert.equal(gate.status().probeOk, true); -}); - -function fakeSpawn() { - const spawned = []; - const spawn = (command, args, options) => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stdout.setEncoding = (encoding) => { child.encoding = encoding; }; - child.kills = 0; - child.kill = () => { child.kills += 1; }; - spawned.push({ command, args, options, child }); - return child; - }; - return { spawn, spawned }; -} - -test("probe: one hidden child, lines become samples, junk is dropped", () => { - const { spawn, spawned } = fakeSpawn(); - const samples = []; - const probe = startProbe({ spawn, scriptPath: "gpu-tenancy-probe.ps1", intervalMs: 15000, onSample: (s) => samples.push(s), parentPid: 4242 }); - assert.equal(spawned.length, 1); - const { command, args, options, child } = spawned[0]; - assert.equal(command, "powershell.exe"); - assert.equal(options.windowsHide, true); - assert.deepEqual(options.stdio, ["ignore", "pipe", "ignore"]); - assert.deepEqual(args, [ - "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", "gpu-tenancy-probe.ps1", "-ParentPid", "4242", "-IntervalMs", "15000", - "-Util3dPath", UTIL3D_COUNTER_PATH, - ]); - assert.equal(child.encoding, "utf8"); - - child.stdout.emit("data", '{"quns":1,"freeMiB":14000,"util3d":2,"engines":4}\n{"quns":2,"free'); - child.stdout.emit("data", 'MiB":9000,"util3d":3,"engines":4}\r\ngarbage\n'); - assert.deepEqual(samples, [ - { quns: 1, freeMiB: 14000, util3d: 2, engines: 4 }, - { quns: 2, freeMiB: 9000, util3d: 3, engines: 4 }, - ]); - probe.stop(); -}); - -test("probe: respawns at most once per interval after the child exits", async () => { - const { spawn, spawned } = fakeSpawn(); - const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 80, onSample: () => {} }); - spawned[0].child.emit("exit", 1); - spawned[0].child.emit("error", new Error("again")); - spawned[0].child.emit("exit", 1); - await sleep(30); - assert.equal(spawned.length, 1, "no respawn inside the interval"); - await sleep(170); - assert.equal(spawned.length, 2, "one respawn for one death, however many events"); - probe.stop(); - spawned[1].child.emit("exit", 1); - await sleep(200); - assert.equal(spawned.length, 2, "a stopped probe does not respawn"); -}); - -test("probe: a plain exit and a spawn error each respawn", async () => { - const exited = fakeSpawn(); - const a = startProbe({ spawn: exited.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {}, staleIntervals: 1000 }); - exited.spawned[0].child.emit("exit", 0); - await sleep(120); - assert.equal(exited.spawned.length, 2); - a.stop(); - const failed = fakeSpawn(); - const b = startProbe({ spawn: failed.spawn, scriptPath: "p.ps1", intervalMs: 20, onSample: () => {}, staleIntervals: 1000 }); - failed.spawned[0].child.emit("error", new Error("ENOENT")); - await sleep(120); - assert.equal(failed.spawned.length, 2); - b.stop(); -}); - -test("probe watchdog: a child that stays alive but sends nothing is killed and respawned once", async () => { - const { spawn, spawned } = fakeSpawn(); - const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 40, onSample: () => {} }); - await sleep(90); - assert.equal(spawned[0].child.kills, 0, "under 3 intervals of silence the child is left alone"); - assert.equal(spawned.length, 1); - await sleep(100); - assert.equal(spawned[0].child.kills, 1, "the hung child is killed exactly once"); - assert.equal(spawned.length, 2, "and replaced"); - spawned[0].child.emit("exit", 1); - const line = '{"quns":1,"freeMiB":14000,"util3d":1,"engines":4}\n'; - for (let i = 0; i < 4; i += 1) { - await sleep(40); - spawned[1].child.stdout.emit("data", line); - } - assert.equal(spawned.length, 2, "the killed child's exit adds no second respawn"); - assert.equal(spawned[1].child.kills, 0); - probe.stop(); -}); - -test("probe watchdog: samples keep a child alive, and a dead child is not killed again", async () => { - const { spawn, spawned } = fakeSpawn(); - const probe = startProbe({ spawn, scriptPath: "p.ps1", intervalMs: 40, onSample: () => {} }); - const line = '{"quns":1,"freeMiB":14000,"util3d":1,"engines":4}\n'; - for (let i = 0; i < 10; i += 1) { - await sleep(40); - spawned[0].child.stdout.emit("data", line); - } - assert.equal(spawned[0].child.kills, 0, "a child that samples is never killed"); - assert.equal(spawned.length, 1); - spawned[0].child.emit("exit", 1); - await sleep(400); - assert.equal(spawned[0].child.kills, 0, "a child that already exited is not killed"); - probe.stop(); -}); diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index 7f1ac4b..dbb3dab 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -2,204 +2,23 @@ import assert from "node:assert/strict"; import test from "node:test"; import { admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, placementRefusal } from "./gpu-tenancy.mjs"; -const SEC = 1000; -const CLEAR = { quns: 1, freeMiB: 14000, util3d: 1, engines: 8 }; - -function rig(options = {}) { - let t = 1_000_000; - const gate = createGpuTenancy({ now: () => t, ...options }); - return { - gate, - t0: t, - at: () => t, - advance: (ms) => { t += ms; }, - feed(sample, afterMs = 15 * SEC) { - t += afterMs; - gate.observe(sample); - }, - }; -} - -function freeRig(options) { - const r = rig(options); - for (let i = 0; i < 13; i += 1) r.feed(CLEAR); // the 13th sample is 180 s after the first - assert.equal(r.gate.status().inUse, false, "precondition: gate is free"); - return r; -} - -test("hysteresis: busy holds for the clear window", () => { - const r = freeRig(); - r.feed({ ...CLEAR, quns: 3 }); - const busyAt = r.at(); - assert.deepEqual(r.gate.status(), { inUse: true, since: busyAt, checkedAt: busyAt, probeOk: true }); - for (let elapsed = 15; elapsed < 180; elapsed += 15) { - r.feed(CLEAR); - assert.equal(r.gate.status().inUse, true, `still held ${elapsed} s into the hold`); - assert.equal(r.gate.status().since, busyAt); - } - // a busy sample late in the hold restarts it - r.feed({ ...CLEAR, quns: 2 }); - const restartAt = r.at(); - r.feed(CLEAR); // the hold counts from the first clear sample - const holdStart = r.at(); - for (let elapsed = 15; elapsed <= 165; elapsed += 15) { - r.feed(CLEAR); - assert.equal(r.gate.status().inUse, true, `restarted hold, ${elapsed} s clear`); - } - r.feed(CLEAR); - assert.equal(r.at() - holdStart, 180 * SEC); - assert.ok(r.at() > restartAt); - const freedAt = r.at(); - assert.deepEqual(r.gate.status(), { inUse: false, since: freedAt, checkedAt: freedAt, probeOk: true }); - r.feed(CLEAR); - assert.equal(r.gate.status().since, freedAt, "since moves only when the state turns"); - r.feed({ ...CLEAR, freeMiB: 100 }); - assert.deepEqual(r.gate.status(), { inUse: true, since: r.at(), checkedAt: r.at(), probeOk: true }); -}); - -test("hysteresis: a busy sample just before the window ends restarts it", () => { - const r = rig(); - r.feed(CLEAR); - for (let i = 0; i < 11; i += 1) r.feed(CLEAR); - assert.equal(r.gate.status().inUse, true, "165 s clear is not enough"); - r.feed({ ...CLEAR, quns: 3 }); - r.feed(CLEAR); - r.feed(CLEAR); - assert.equal(r.gate.status().inUse, true); -}); - -test("sustained 3D, immediate full-screen and VRAM", () => { - const spike = freeRig(); - spike.feed({ ...CLEAR, util3d: 80 }); - spike.feed(CLEAR); - assert.equal(spike.gate.status().inUse, false, "one 3D spike between clear samples"); - - const sustained = freeRig(); - sustained.feed({ ...CLEAR, util3d: 80 }); - assert.equal(sustained.gate.status().inUse, false, "first high sample alone"); - sustained.feed({ ...CLEAR, util3d: 80 }); - assert.equal(sustained.gate.status().inUse, true, "two consecutive high samples"); - - const edge = freeRig(); - edge.feed({ ...CLEAR, util3d: 20 }); - edge.feed({ ...CLEAR, util3d: 20 }); - assert.equal(edge.gate.status().inUse, false, "exactly the threshold is not above it"); - edge.feed({ ...CLEAR, util3d: 80 }); - edge.feed({ ...CLEAR, util3d: 20 }); - assert.equal(edge.gate.status().inUse, false, "high then exactly the threshold"); - const edgeUp = freeRig(); - edgeUp.feed({ ...CLEAR, util3d: 20 }); - edgeUp.feed({ ...CLEAR, util3d: 80 }); - assert.equal(edgeUp.gate.status().inUse, false, "exactly the threshold then high"); - - for (const [name, sample] of [ - ["QUNS_BUSY", { ...CLEAR, quns: 2 }], - ["QUNS_RUNNING_D3D_FULL_SCREEN", { ...CLEAR, quns: 3 }], - ["low free VRAM", { ...CLEAR, freeMiB: 10239 }], - ]) { - const r = freeRig(); - r.feed(sample); - assert.equal(r.gate.status().inUse, true, name); - } - - const enough = freeRig(); - enough.feed({ ...CLEAR, freeMiB: 10240 }); - assert.equal(enough.gate.status().inUse, false, "free VRAM equal to the minimum is enough"); - - const otherStates = freeRig(); - for (const quns of [1, 4, 5, 6, 7]) otherStates.feed({ ...CLEAR, quns }); - assert.equal(otherStates.gate.status().inUse, false, "other notification states are not household use"); -}); - -test("thresholds from the options are honored", () => { - const r = rig({ minFreeMiB: 500, util3dBusyPct: 90, clearHoldMs: 30 * SEC }); - r.feed({ ...CLEAR, freeMiB: 600, util3d: 85 }); - r.feed({ ...CLEAR, freeMiB: 600, util3d: 85 }); - r.feed({ ...CLEAR, freeMiB: 600, util3d: 85 }); - assert.equal(r.gate.status().inUse, false); - r.feed({ ...CLEAR, freeMiB: 400 }); - assert.equal(r.gate.status().inUse, true); -}); - -test("start and stale fail closed", () => { - const r = rig(); - assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: true }); - for (let i = 0; i < 13; i += 1) r.feed(CLEAR); - assert.equal(r.gate.status().inUse, false); - const lastSample = r.at(); - - r.advance(45 * SEC); - assert.equal(r.gate.status().probeOk, true, "exactly 3 intervals is not stale yet"); - r.advance(1); - const stale = r.gate.status(); - assert.equal(stale.inUse, true); - assert.equal(stale.probeOk, false); - assert.equal(stale.since, lastSample + 45 * SEC); - assert.equal(stale.checkedAt, lastSample); - - r.feed(CLEAR, 0); - assert.equal(r.gate.status().inUse, true, "samples resumed but the hold has not passed"); - assert.equal(r.gate.status().probeOk, true); - for (let i = 0; i < 11; i += 1) r.feed(CLEAR); - assert.equal(r.gate.status().inUse, true); - r.feed(CLEAR); - assert.equal(r.gate.status().inUse, false); -}); - -test("a stale start keeps since at the start when samples resume busy", () => { - const r = rig(); - r.advance(60 * SEC); - r.feed({ ...CLEAR, quns: 3 }, 0); - assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: r.at(), probeOk: true }); -}); - -test("a gate that never hears from its probe stays in use and not ok", () => { - const r = rig(); - r.advance(46 * SEC); - assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: false }); -}); - -test("defer, not kill", async () => { - const r = freeRig(); +test("slot: a second request while a job runs is refused as a slot refusal and never waits", async () => { + const gate = createGpuTenancy(); let release; - let started = 0; - const running = admitThen(r.gate, () => { - started += 1; - return new Promise((resolve) => { release = () => resolve({ ok: true, value: "finished" }); }); - }); - assert.equal(started, 1); - r.feed({ ...CLEAR, quns: 3 }); - assert.equal(r.gate.status().inUse, true, "the gate went busy while the job runs"); - - let secondCalls = 0; - const second = await admitThen(r.gate, async () => { secondCalls += 1; }); - assert.equal(secondCalls, 0, "a second job is refused without being called"); - assert.equal(second.code, "gpu-busy"); - - release(); - assert.deepEqual(await running, { ok: true, value: "finished" }); -}); - -test("a job that fails is not swallowed by the gate", async () => { - const r = freeRig(); - await assert.rejects(admitThen(r.gate, async () => { throw new Error("job failed"); }), /job failed/); -}); - -test("typed busy result", async () => { - const r = rig(); - r.feed({ ...CLEAR, quns: 3 }); - const since = r.gate.status().since; - r.feed({ ...CLEAR, quns: 3 }); - r.feed({ ...CLEAR, quns: 3 }); + const running = admitThen(gate, () => new Promise((resolve) => { release = () => resolve("done"); })); let calls = 0; - const refusal = await admitThen(r.gate, async () => { calls += 1; }); - assert.deepEqual(refusal, { - ok: false, - code: "gpu-busy", - source: "admission", - gpu: { inUse: true, since, checkedAt: r.at(), probeOk: true }, - }); + const refused = await admitThen(gate, async () => { calls += 1; }); + assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "slot" }); assert.equal(calls, 0); + release(); + assert.equal(await running, "done"); + assert.equal(await admitThen(gate, async () => "next"), "next", "the slot frees when the job ends"); +}); + +test("slot: a failing job still frees the slot", async () => { + const gate = createGpuTenancy(); + await assert.rejects(admitThen(gate, async () => { throw new Error("boom"); }), /boom/); + assert.equal(await admitThen(gate, async () => "after"), "after"); }); test("placement refusal is keyed on the typed code, never on text, and carries no gate status", () => { @@ -211,176 +30,18 @@ test("placement refusal is keyed on the typed code, never on text, and carries n assert.equal(placementRefusal(null, "vili-1"), null); }); - -// ---- the slot: one job at a time, judged where the GPU is taken ---- - -const OWN = { ...CLEAR, freeMiB: 1800 }; // the encoder child holds the VRAM - -test("slot: a second request while a job runs is refused as a slot refusal and never waits", async () => { - const r = freeRig(); - let release; - const running = admitThen(r.gate, () => new Promise((resolve) => { release = () => resolve("done"); })); - let calls = 0; - const refused = await admitThen(r.gate, async () => { calls += 1; }); - assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "slot" }); - assert.equal(calls, 0); - const third = await admitThen(r.gate, async () => { calls += 1; }); - assert.equal(third.source, "slot"); - release(); - assert.equal(await running, "done"); - assert.equal(await admitThen(r.gate, async () => "next"), "next", "the slot frees when the job ends"); -}); - -test("slot: a failing job still frees the slot", async () => { - const r = freeRig(); - await assert.rejects(admitThen(r.gate, async () => { throw new Error("boom"); }), /boom/); - assert.equal(await admitThen(r.gate, async () => "after"), "after"); -}); - -test("slot: the household's answer comes before the slot's", async () => { - const r = freeRig(); - const running = admitThen(r.gate, () => new Promise(() => {})); - r.feed({ ...OWN, quns: 3 }); - const refused = await admitThen(r.gate, async () => {}); - assert.equal(refused.source, "admission"); - assert.equal(refused.gpu.inUse, true); - void running; -}); - -test("own VRAM: a sequential batch of misses is never refused for Vili's own load", async () => { - const r = freeRig(); - for (let miss = 1; miss <= 8; miss += 1) { - const result = await admitThen(r.gate, async () => { - for (let i = 0; i < 9; i += 1) r.feed(OWN); // about 2 minutes of child VRAM - return `miss ${miss}`; - }); - assert.equal(result, `miss ${miss}`, `miss ${miss} was admitted`); - r.feed(CLEAR); // the child's VRAM has drained - assert.equal(r.gate.status().inUse, false, `gate free after miss ${miss}`); - } -}); - -test("own VRAM: the next request right after a job is admitted while the child's VRAM drains", async () => { - const r = freeRig(); - await admitThen(r.gate, async () => { r.feed(OWN); r.feed(OWN); }); - r.feed(OWN); // still draining after the slot freed - assert.equal(r.gate.status().inUse, false); - const second = await admitThen(r.gate, async () => { - r.feed(OWN); - return "second"; - }); - assert.equal(second, "second"); - r.feed({ ...CLEAR, freeMiB: 5000 }); - assert.equal(r.gate.status().inUse, false, "the baseline survives back-to-back jobs: 5000 is not recovery from a 14000 start"); - r.feed(CLEAR); - assert.equal(r.gate.status().inUse, false); - r.feed({ ...CLEAR, freeMiB: 5000 }); - assert.equal(r.gate.status().inUse, true, "recovered, so low VRAM is the household's again"); -}); - -test("slot: releasing twice does not restart the drain", () => { - const r = freeRig(); - const slot = r.gate.acquire(); - r.feed(OWN); - slot.release(); - r.feed(OWN); - r.feed(OWN); - r.feed(OWN); - slot.release(); // a second release is nothing: it must not give the drain a fresh cap - r.feed(OWN); - assert.equal(r.gate.status().inUse, true, "60 s after the one release, the drain cap has passed"); -}); - -test("own VRAM: low VRAM that outlives the drain is the household's, and refuses", async () => { - const r = freeRig({ sampleIntervalMs: 15 * SEC }); - await admitThen(r.gate, async () => { r.feed(OWN); }); - for (let i = 0; i < 3; i += 1) { - r.feed(OWN); - assert.equal(r.gate.status().inUse, false, `draining ${i}`); - } - r.feed(OWN); // 60 s after the release: the drain cap - assert.equal(r.gate.status().inUse, true); - assert.equal((await admitThen(r.gate, async () => {})).source, "admission"); -}); - -test("own VRAM: partial recovery within the tolerance ends the drain, below it does not", async () => { - const within = freeRig(); - await admitThen(within.gate, async () => { within.feed(OWN); }); - within.feed({ ...CLEAR, freeMiB: 14000 - 1024 }); - within.feed({ ...CLEAR, freeMiB: 5000 }); - assert.equal(within.gate.status().inUse, true, "recovered, then VRAM below the minimum counts again"); - - const below = freeRig(); - await admitThen(below.gate, async () => { below.feed(OWN); }); - below.feed({ ...CLEAR, freeMiB: 14000 - 1025 }); - below.feed({ ...CLEAR, freeMiB: 5000 }); - assert.equal(below.gate.status().inUse, false, "still draining: not recovered to within the tolerance"); -}); - -test("household signals mid-job count, and the next job is refused", async () => { - for (const [name, sample] of [ - ["full-screen", { ...OWN, quns: 2 }], - ["exclusive full-screen", { ...OWN, quns: 3 }], - ]) { - const r = freeRig(); - await admitThen(r.gate, async () => { - r.feed(OWN); - r.feed(sample); - }); - const refused = await admitThen(r.gate, async () => {}); - assert.equal(refused.code, "gpu-busy", name); - assert.equal(refused.source, "admission", name); - } - const sustained = freeRig(); - await admitThen(sustained.gate, async () => { - sustained.feed({ ...OWN, util3d: 90 }); - sustained.feed({ ...OWN, util3d: 90 }); - }); - assert.equal((await admitThen(sustained.gate, async () => {})).source, "admission", "sustained 3D mid-job"); -}); - -test("household VRAM that arrives mid-job is still there when the drain ends", async () => { - const r = freeRig(); - await admitThen(r.gate, async () => { r.feed(OWN); }); - for (let i = 0; i < 4; i += 1) r.feed({ ...OWN, freeMiB: 600 }); - assert.equal(r.gate.status().inUse, true); - assert.equal((await admitThen(r.gate, async () => {})).source, "admission"); -}); - -// ---- the sample that cannot see the 3D engine ---- - -test("a sample with no resolved 3D counters is a probe fault, not a clear sample", () => { - const r = rig(); - for (let i = 0; i < 20; i += 1) r.feed({ ...CLEAR, engines: 0 }); - assert.deepEqual(r.gate.status(), { inUse: true, since: r.t0, checkedAt: null, probeOk: false }); - r.feed({ ...CLEAR, engines: 1 }); - assert.equal(r.gate.status().checkedAt, r.at(), "one resolved counter is enough"); -}); - -// ---- operator overrides ---- - -test("env overrides: valid values are read, absent ones are left to the defaults", () => { +test("env override: a valid value is read, absent is left to the default", () => { assert.deepEqual(gpuTenancyOptionsFromEnv({}), {}); - assert.deepEqual(gpuTenancyOptionsFromEnv({ - VILI_GPU_SAMPLE_MS: "5000", - VILI_GPU_CLEAR_HOLD_MS: "0", - VILI_GPU_MIN_FREE_MIB: "8192.5", - VILI_GPU_UTIL3D_BUSY_PCT: "35", - }), { sampleIntervalMs: 5000, clearHoldMs: 0, minFreeMiB: 8192.5, util3dBusyPct: 35 }); + assert.deepEqual(gpuTenancyOptionsFromEnv({ VILI_GPU_MIN_FREE_MIB: "8192.5" }), { minFreeMiB: 8192.5 }); + assert.deepEqual(gpuTenancyOptionsFromEnv({ VILI_GPU_MIN_FREE_MIB: "0" }), { minFreeMiB: 0 }); }); -test("env overrides: a malformed value refuses to start and names the variable, not the value", () => { - for (const name of ["VILI_GPU_SAMPLE_MS", "VILI_GPU_CLEAR_HOLD_MS", "VILI_GPU_MIN_FREE_MIB", "VILI_GPU_UTIL3D_BUSY_PCT"]) { - for (const bad of ["10240MiB", "", " ", "NaN", "Infinity", "-5"]) { - assert.throws( - () => gpuTenancyOptionsFromEnv({ [name]: bad }), - (error) => error.message.includes(name) && (bad.trim() === "" || !error.message.includes(bad)), - `${name}=${JSON.stringify(bad)}`, - ); - } +test("env override: a malformed value refuses to start and names the variable, not the value", () => { + for (const bad of ["10240MiB", "", " ", "NaN", "Infinity", "-5"]) { + assert.throws( + () => gpuTenancyOptionsFromEnv({ VILI_GPU_MIN_FREE_MIB: bad }), + (error) => error.message === "VILI_GPU_MIN_FREE_MIB must be a finite non-negative number", + JSON.stringify(bad), + ); } - assert.throws(() => gpuTenancyOptionsFromEnv({ VILI_GPU_SAMPLE_MS: "0" }), { message: "VILI_GPU_SAMPLE_MS must be a finite positive number" }); - assert.throws(() => gpuTenancyOptionsFromEnv({ VILI_GPU_MIN_FREE_MIB: "x" }), { message: "VILI_GPU_MIN_FREE_MIB must be a finite non-negative number" }); - assert.deepEqual(gpuTenancyOptionsFromEnv({ VILI_GPU_CLEAR_HOLD_MS: "0", VILI_GPU_MIN_FREE_MIB: "0", VILI_GPU_UTIL3D_BUSY_PCT: "0" }), - { clearHoldMs: 0, minFreeMiB: 0, util3dBusyPct: 0 }, "zero is a valid hold, minimum and threshold"); }); diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index 372b2ae..a2ef9b5 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -4,9 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import { execFile, spawn } from "node:child_process"; +import { execFile } from "node:child_process"; import { WebSocketServer } from "ws"; -import { startProbe } from "./gpu-probe.mjs"; import { RETRY_AFTER_SECONDS, admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, placementRefusal } from "./gpu-tenancy.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -139,7 +138,7 @@ function parseArgs(argv) { const args = parseArgs(process.argv.slice(2)); const startedAt = new Date().toISOString(); const gpuOptions = gpuTenancyOptionsFromEnv(process.env); -const gpuTenancy = createGpuTenancy({ now: Date.now, ...gpuOptions }); +const gpuTenancy = createGpuTenancy(gpuOptions); let backendStatus = { checkedAt: null, dockerImage: "unknown", @@ -764,13 +763,4 @@ if (args.health) { }); setInterval(() => persistSurfaces().catch((error) => console.error(error)), 5000); - - if (process.platform === "win32") { - startProbe({ - spawn, - scriptPath: path.join(__dirname, "gpu-tenancy-probe.ps1"), - intervalMs: gpuOptions.sampleIntervalMs ?? 15000, - onSample: (sample) => gpuTenancy.observe(sample), - }); - } } From 736a1a7ab880b725df5ed2f094c3c82a1c65dd32 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 01:45:08 +0200 Subject: [PATCH 16/26] Build the GPU permission: the household's yes or no, owned by the daemon gpu-tenancy.mjs judges permission, then the one slot, then one host free-VRAM read per admission. The permission is a gamecult.vili.gpu_permission.v1 document in vili.service.cc, set only from loopback at POST /gpu-permission; absent means not allowed. The daemon holds one CultCache instance. Tests pin the gate with fake ports and the daemon edge over HTTP, including the slot wiring through acquire() (finding s2 slot-wiring-unpinned). Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++- docs/verse-service-contract.md | 22 ++-- scripts/gpu-tenancy.mjs | 78 ++++++++++++--- scripts/test-gpu-gate-http.mjs | 143 +++++++++++++++++++++++++- scripts/test-gpu-tenancy.mjs | 177 ++++++++++++++++++++++++++++++--- scripts/vili-daemon.mjs | 85 ++++++++++++++-- 6 files changed, 461 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 0a52c1c..e07fa1e 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,17 @@ it has no CPU fallback, and hits never touch it. Each hashes) and the worker identity. `GET /health` shows the identity and the stored count. -GPU tenancy. The daemon holds one GPU slot; a `POST /motion/generate` while another job holds it -is refused with HTTP 503, `retry-after: 60` and `{ ok: false, code: "gpu-busy", source: "slot" }`. -A job already running finishes; the gate never stops work. `npm test` runs the gate's tests. +GPU permission. Raven's GPU is the household's first. A tray icon in the household's session +(`scripts/vili-gpu-tray.ps1`, logon task `GameCult\ViliTray`, installed by +`scripts/install-vili-tray-task.ps1`) shows whether Vili may use the GPU and flips it with one click. +The daemon owns the yes or no in `vili.service.cc` (`gamecult.vili.gpu_permission.v1`): off on a +fresh install, set only from loopback (`POST /gpu-permission { allowed }`, read at +`GET /gpu-permission`). A `POST /motion/generate` is refused with HTTP 503, `retry-after: 60` and +`{ ok: false, code: "gpu-busy", source }` where `source` is `household` (the permission is off), +`slot` (another job holds the one GPU slot) or `vram` (host `nvidia-smi.exe` free VRAM is below +`VILI_GPU_MIN_FREE_MIB`, default 10240, or unreadable); nothing queues, and the consumer owns the +retry. A job already running finishes; the gate never stops work. `operator_state.gpu` is +`{ allowed, changedAt, jobRunning, lastRefusal }`. `npm test` runs the gate's tests. Measured on Raven on `2026-10-22`, for a 2 second clip at 20 diffusion steps: the worker is healthy 29 seconds after `docker rm` (was 179) and rests at 2.3 GiB diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index f53a54c..ad2cbb1 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -46,15 +46,19 @@ promoted from JSON once that request/receipt shape stabilizes. supported Kimodo generation controls to the resident worker. Vili owns job identity, request persistence, worker lifecycle, timeout handling, and the artifact stem. Kimodo owns synthesis and model-specific export. Vili also owns GPU -tenancy admission: the daemon holds one GPU slot, and `POST /motion/generate` is judged -when it reaches the slot. It answers HTTP 503 with `retry-after: 60` and one of three bodies, by -`source`: `{ "ok": false, "code": "gpu-busy", "source": "admission", "gpu": { inUse, since, -checkedAt, probeOk } }` while the household is using the GPU; `{ "ok": false, "code": -"gpu-busy", "source": "slot" }` while another job holds the slot (nothing queues); and `{ -"ok": false, "code": "gpu-busy", "source": "placement", "jobId" }` when the encoder child -refused its own placement, where the job file was written and no household status is -claimed. The `code` field is the only carrier; a consumer retries after `retry-after` and -never matches error text. A job already running finishes. +admission from the household's permission: the daemon holds one GPU slot, and `POST /motion/generate` is judged +when it reaches the slot. It answers HTTP 503 with `retry-after: 60` and one of four bodies, by +`source`: `{ "ok": false, "code": "gpu-busy", "source": "household", "gpu": { allowed: false, +changedAt } }` while the household's permission is off; `{ "ok": false, "code": "gpu-busy", +"source": "slot" }` while another job holds the slot (nothing queues); `{ "ok": false, "code": +"gpu-busy", "source": "vram", freeMiB, minFreeMiB }` when host free VRAM is below the minimum or +unreadable (`freeMiB` null); and `{ "ok": false, "code": "gpu-busy", "source": "placement", +"jobId" }` when the encoder child refused its own placement, where the job file was written and no +household status is claimed. `GET /gpu-permission` answers `{ allowed, changedAt, jobRunning, +lastRefusal }` to anyone; `POST /gpu-permission { "allowed": boolean }` is the household's switch and +answers 403 `{ "ok": false, "code": "household-only" }` to any caller that is not on the host's +loopback. The `code` field is the only carrier; a consumer retries after `retry-after`, never sets +the permission, and never matches error text. A job already running finishes. ```json { diff --git a/scripts/gpu-tenancy.mjs b/scripts/gpu-tenancy.mjs index b6156ff..e26f9b6 100644 --- a/scripts/gpu-tenancy.mjs +++ b/scripts/gpu-tenancy.mjs @@ -1,28 +1,68 @@ -// GPU tenancy gate. The detection of the household's use is deleted; this file holds the one GPU -// slot only, until the household's permission lands. It refuses new work and never stops work. +// GPU tenancy gate: the one owner of "may heavy GPU work start now". The household decides, through +// the permission it sets at the tray; the daemon owns that yes or no, the one GPU slot, and one host +// free-VRAM read per admission. The gate refuses new work; it never stops work. +// +// acquire() judges in a fixed order and nothing waits: the permission, then the slot, then host free +// VRAM. The slot is taken before the VRAM read so concurrent requests are refused at once. export const RETRY_AFTER_SECONDS = 60; -export function createGpuTenancy({ minFreeMiB = 10240 } = {}) { +export function createGpuTenancy({ now, permission, savePermission, readFreeMiB, minFreeMiB = 10240 }) { + let allowed = permission?.allowed === true; + let changedAt = permission?.changedAt ?? null; let held = false; - return { - status: () => ({ jobRunning: held, minFreeMiB }), - acquire() { - if (held) return { refusal: { ok: false, code: "gpu-busy", source: "slot" } }; - held = true; - return { - release() { - held = false; - }, - }; - }, - }; + let lastRefusal = null; + + const status = () => ({ allowed, changedAt, jobRunning: held, lastRefusal }); + + function refuse(source, extra = {}) { + lastRefusal = { at: new Date(now()).toISOString(), source }; + return { refusal: { ok: false, code: "gpu-busy", source, ...extra } }; + } + const household = () => refuse("household", { gpu: { allowed: false, changedAt } }); + + async function setAllowed(next) { + if (typeof next !== "boolean") throw new TypeError("allowed must be a boolean"); + if (next === allowed) return status(); + const value = { allowed: next, changedAt: new Date(now()).toISOString() }; + await savePermission(value); + allowed = value.allowed; + changedAt = value.changedAt; + return status(); + } + + async function acquire() { + if (!allowed) return household(); + if (held) return refuse("slot"); + held = true; + let freeMiB; + try { + freeMiB = await readFreeMiB(); + } catch { + freeMiB = null; + } + if (!allowed) { + held = false; + return household(); + } + if (!Number.isFinite(freeMiB) || freeMiB < minFreeMiB) { + held = false; + return refuse("vram", { freeMiB: Number.isFinite(freeMiB) ? freeMiB : null, minFreeMiB }); + } + return { + release() { + held = false; + }, + }; + } + + return { status, setAllowed, acquire }; } // Takes the slot, runs the job to its end and returns its result, or returns the typed refusal // without calling the job. The gate never holds, pauses or cancels a job. export async function admitThen(gate, job) { - const slot = gate.acquire(); + const slot = await gate.acquire(); if (slot.refusal) return slot.refusal; try { return await job(); @@ -31,6 +71,12 @@ export async function admitThen(gate, job) { } } +// nvidia-smi's first line is the free MiB as a base-10 integer; anything else is null. +export function parseFreeMiB(stdout) { + const line = String(stdout ?? "").split("\n")[0].trim(); + return /^\d+$/.test(line) ? Number(line) : null; +} + // The encoder child's placement refusal reaches the daemon as a typed code on the worker's error body; // it is never read out of error text. Placement is the child's own view of free VRAM, not the // household gate, so the body carries no gate status. Returns the typed 503 body, or null for any diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 4d1b3b0..9fe62a2 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -1,19 +1,152 @@ // Boots the real daemon and observes the gate where it is decided: the HTTP edge. Needs the -// daemon's own dependencies (ws, cultcache-ts), so it runs on a machine that has them. +// daemon's own dependencies (ws, cultcache-ts), so it runs on a machine that has them. Every boot +// sets VILI_GPU_MIN_FREE_MIB=999999 so an allowed request ends in a VRAM refusal and never starts +// the worker: no test here may start a GPU job. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; const daemonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "vili-daemon.mjs"); -const port = 18000 + Math.floor(Math.random() * 1000); +let nextPort = 18000 + Math.floor(Math.random() * 1000); -test("a malformed GPU override refuses to start and names the variable, not its value", async () => { +async function bootDaemon(stateRoot, { host = "127.0.0.1", env = { VILI_GPU_MIN_FREE_MIB: "999999" } } = {}) { + const port = nextPort++; + const child = spawn(process.execPath, [daemonPath, "--host", host, "--port", String(port), "--state-root", stateRoot], { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + const deadline = Date.now() + 30000; + while (!output.includes("Vili listening")) { + if (child.exitCode !== null || Date.now() > deadline) throw new Error(`daemon did not start: ${output.slice(-500)}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const stop = () => new Promise((resolve) => { + if (child.exitCode !== null) resolve(); + else { child.once("exit", resolve); child.kill(); } + }); + return { child, port, stop }; +} + +const url = (d, route, host = "127.0.0.1") => `http://${host}:${d.port}${route}`; +const generate = (d, body) => fetch(url(d, "/motion/generate"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); +const setPermission = (d, body, host = "127.0.0.1") => fetch(url(d, "/gpu-permission", host), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); +const readPermission = async (d) => (await fetch(url(d, "/gpu-permission"))).json(); + +test("http: refusal at the daemon edge", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-")); + const d = await bootDaemon(stateRoot); + try { + assert.deepEqual(await readPermission(d), { allowed: false, changedAt: null, jobRunning: false, lastRefusal: null }); + assert.equal((await generate(d, {})).status, 400, "request-shape errors come ahead of the gate"); + + const refused = await generate(d, { prompt: "a person waves hello, never seen before" }); + assert.equal(refused.status, 503); + assert.equal(refused.headers.get("retry-after"), "60"); + assert.deepEqual(await refused.json(), { ok: false, code: "gpu-busy", source: "household", gpu: { allowed: false, changedAt: null } }); + + assert.deepEqual(await readdir(path.join(stateRoot, "jobs")), [], "no job file"); + assert.equal((await readdir(stateRoot)).includes("artifacts"), false, "no artifact directory"); + + const state = await (await fetch(url(d, "/operator-state"))).json(); + assert.equal(state.gpu.lastRefusal.source, "household"); + assert.equal(state.gpu.allowed, false); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); + +test("http: the permission persists across a daemon restart", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-persist-")); + let d = await bootDaemon(stateRoot); + try { + const on = await setPermission(d, { allowed: true }); + assert.equal(on.status, 200); + const onBody = await on.json(); + assert.equal(onBody.ok, true); + assert.equal(onBody.gpu.allowed, true); + const before = await readPermission(d); + assert.equal(before.allowed, true); + assert.ok(before.changedAt); + + assert.equal((await setPermission(d, { allowed: "yes" })).status, 400); + assert.equal((await setPermission(d, {})).status, 400); + assert.deepEqual(await readPermission(d), before, "a refused write changes nothing"); + + await d.stop(); + d = await bootDaemon(stateRoot); + const after = await readPermission(d); + assert.equal(after.allowed, true); + assert.equal(after.changedAt, before.changedAt); + + await setPermission(d, { allowed: false }); + await d.stop(); + d = await bootDaemon(stateRoot); + assert.equal((await readPermission(d)).allowed, false, "off persists too"); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); + +test("http: an allowed request reaches the slot and the VRAM read", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-slot-")); + const d = await bootDaemon(stateRoot); + try { + await setPermission(d, { allowed: true }); + const refused = await generate(d, { prompt: "a person waves hello, never seen before" }); + assert.equal(refused.status, 503); + const body = await refused.json(); + assert.equal(body.source, "vram", "admitted through acquire(), then refused for VRAM"); + assert.equal(body.minFreeMiB, 999999); + assert.deepEqual(await readdir(path.join(stateRoot, "jobs")), [], "no job file"); + assert.equal((await readdir(stateRoot)).includes("artifacts"), false); + const state = await readPermission(d); + assert.equal(state.lastRefusal.source, "vram"); + assert.equal(state.jobRunning, false); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); + +test("http: the switch is the household's", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-lan-")); + const d = await bootDaemon(stateRoot, { host: "0.0.0.0" }); + try { + const lan = Object.values(os.networkInterfaces()).flat().find((i) => i.family === "IPv4" && !i.internal)?.address; + assert.ok(lan, "the host has a non-loopback IPv4 address"); + const before = await readPermission(d); + const refused = await setPermission(d, { allowed: true }, lan); + assert.equal(refused.status, 403); + assert.deepEqual(await refused.json(), { ok: false, code: "household-only" }); + assert.deepEqual(await readPermission(d), before, "GET still reads the previous value"); + assert.equal((await setPermission(d, { allowed: true })).status, 200, "loopback still sets it"); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); + +test("http: a malformed override refuses to start and names the variable, not its value", async () => { const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-env-")); - const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(port + 1), "--state-root", stateRoot], { + const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(nextPort++), "--state-root", stateRoot], { env: { ...process.env, VILI_GPU_MIN_FREE_MIB: "10240MiB-secret" }, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index dbb3dab..2d192c7 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -1,24 +1,173 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, placementRefusal } from "./gpu-tenancy.mjs"; +import { admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, parseFreeMiB, placementRefusal } from "./gpu-tenancy.mjs"; -test("slot: a second request while a job runs is refused as a slot refusal and never waits", async () => { - const gate = createGpuTenancy(); - let release; - const running = admitThen(gate, () => new Promise((resolve) => { release = () => resolve("done"); })); +function deferred() { + let resolve; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +// Fake ports: a clock, a store spy and a free-VRAM source. No GPU, no daemon, no cache. +function rig({ permission, free = 12000, minFreeMiB } = {}) { + const r = { t: Date.parse("2026-10-23T10:00:00Z"), saved: [], reads: 0, free, failSave: false }; + r.gate = createGpuTenancy({ + now: () => r.t, + permission, + minFreeMiB, + savePermission: async (value) => { + if (r.failSave) throw new Error("disk full"); + r.saved.push(value); + }, + readFreeMiB: async () => { + r.reads += 1; + if (typeof r.free === "function") return r.free(); + return r.free; + }, + }); + return r; +} +const ON = { allowed: true, changedAt: "2026-10-23T09:00:00.000Z" }; + +test("gate: toggle off refuses and creates no job", async () => { + const r = rig(); let calls = 0; - const refused = await admitThen(gate, async () => { calls += 1; }); - assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "slot" }); + const refused = await admitThen(r.gate, async () => { calls += 1; }); + assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "household", gpu: { allowed: false, changedAt: null } }); assert.equal(calls, 0); - release(); - assert.equal(await running, "done"); - assert.equal(await admitThen(gate, async () => "next"), "next", "the slot frees when the job ends"); + assert.equal(r.reads, 0, "the VRAM is never read for a household refusal"); + assert.deepEqual(r.gate.status(), { + allowed: false, + changedAt: null, + jobRunning: false, + lastRefusal: { at: "2026-10-23T10:00:00.000Z", source: "household" }, + }); }); -test("slot: a failing job still frees the slot", async () => { - const gate = createGpuTenancy(); - await assert.rejects(admitThen(gate, async () => { throw new Error("boom"); }), /boom/); - assert.equal(await admitThen(gate, async () => "after"), "after"); +test("gate: toggle on admits", async () => { + const r = rig({ permission: ON }); + let calls = 0; + const result = await admitThen(r.gate, async () => { + calls += 1; + assert.equal(r.gate.status().jobRunning, true); + return { ok: true, value: "finished" }; + }); + assert.deepEqual(result, { ok: true, value: "finished" }); + assert.equal(calls, 1); + assert.equal(r.gate.status().jobRunning, false); + assert.equal(r.gate.status().lastRefusal, null); +}); + +test("gate: toggle off mid-job lets the job finish and refuses the next", async () => { + const r = rig({ permission: ON }); + const end = deferred(); + const running = admitThen(r.gate, async () => { await end.promise; return { ok: true, value: "finished" }; }); + while (!r.gate.status().jobRunning) await new Promise((resolve) => setImmediate(resolve)); + const off = await r.gate.setAllowed(false); + assert.equal(off.allowed, false); + assert.equal(off.jobRunning, true, "the job is still running"); + let calls = 0; + const next = await admitThen(r.gate, async () => { calls += 1; }); + assert.equal(next.source, "household"); + assert.equal(calls, 0); + end.resolve(); + assert.deepEqual(await running, { ok: true, value: "finished" }); + assert.equal(r.gate.status().jobRunning, false); +}); + +test("gate: the concurrent slot behaves as h2's did", async () => { + const vram = deferred(); + const r = rig({ permission: ON, free: () => vram.promise }); + const end = deferred(); + let calls = 0; + const job = async () => { calls += 1; await end.promise; return "done"; }; + const all = [admitThen(r.gate, job), admitThen(r.gate, job), admitThen(r.gate, job)]; + // the two losers are decided before the VRAM read resolves + const losers = await Promise.all([all[1], all[2]]); + assert.deepEqual(losers, [{ ok: false, code: "gpu-busy", source: "slot" }, { ok: false, code: "gpu-busy", source: "slot" }]); + assert.equal(r.reads, 1, "only the slot holder reads VRAM"); + vram.resolve(12000); + end.resolve(); + assert.equal(await all[0], "done"); + assert.equal(calls, 1); + assert.equal(r.gate.status().jobRunning, false); + + await assert.rejects(admitThen(r.gate, async () => { throw new Error("boom"); }), /boom/); + assert.equal(r.gate.status().jobRunning, false, "a throwing job frees the slot"); + r.free = 100; + assert.equal((await admitThen(r.gate, async () => "x")).source, "vram"); + const readsBefore = r.reads; + r.free = 12000; + assert.equal(await admitThen(r.gate, async () => "again"), "again", "a vram refusal frees the slot"); + assert.equal(r.reads, readsBefore + 1, "the next admission reads VRAM again"); +}); + +test("gate: VRAM read fails closed", async () => { + const cases = [[9000, 9000], [10239, 10239], [null, null], [Number.NaN, null], [() => { throw new Error("smi"); }, null]]; + for (const [free, expected] of cases) { + const r = rig({ permission: ON, free }); + let calls = 0; + const refused = await admitThen(r.gate, async () => { calls += 1; }); + assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "vram", freeMiB: expected, minFreeMiB: 10240 }); + assert.equal(calls, 0); + assert.equal(r.gate.status().jobRunning, false); + assert.equal(r.gate.status().lastRefusal.source, "vram"); + } + const edge = rig({ permission: ON, free: 10240 }); + assert.equal(await admitThen(edge.gate, async () => "in"), "in", "exactly the minimum admits"); + const custom = rig({ permission: ON, free: 5000, minFreeMiB: 4096 }); + assert.equal(await admitThen(custom.gate, async () => "in"), "in", "the minimum is the configured one"); +}); + +test("gate: a toggle during the VRAM read wins", async () => { + const vram = deferred(); + const r = rig({ permission: ON, free: () => vram.promise }); + let calls = 0; + const pending = admitThen(r.gate, async () => { calls += 1; }); + await r.gate.setAllowed(false); + vram.resolve(12000); + const refused = await pending; + assert.equal(refused.source, "household"); + assert.equal(calls, 0); + assert.equal(r.gate.status().jobRunning, false); +}); + +test("gate: setAllowed saves before it answers", async () => { + const r = rig(); + r.failSave = true; + await assert.rejects(r.gate.setAllowed(true), /disk full/); + assert.equal(r.gate.status().allowed, false, "a failed save leaves the state unchanged"); + assert.equal(r.gate.status().changedAt, null); + r.failSave = false; + + await assert.rejects(r.gate.setAllowed("yes"), TypeError); + await assert.rejects(r.gate.setAllowed(undefined), TypeError); + assert.deepEqual(r.saved, [], "a non-boolean saves nothing"); + + r.t += 1000; + const on = await r.gate.setAllowed(true); + assert.deepEqual(r.saved, [{ allowed: true, changedAt: "2026-10-23T10:00:01.000Z" }]); + assert.equal(on.allowed, true); + assert.equal(on.changedAt, "2026-10-23T10:00:01.000Z"); + + r.t += 5000; + const same = await r.gate.setAllowed(true); + assert.equal(r.saved.length, 1, "a same-value call saves nothing"); + assert.equal(same.changedAt, "2026-10-23T10:00:01.000Z", "and keeps changedAt"); +}); + +test("gate: a stored permission is the starting state", () => { + assert.deepEqual(rig({ permission: ON }).gate.status(), { allowed: true, changedAt: ON.changedAt, jobRunning: false, lastRefusal: null }); + assert.equal(rig({ permission: { allowed: "yes", changedAt: "x" } }).gate.status().allowed, false, "only a true boolean allows"); +}); + +test("parseFreeMiB reads the first line as a base-10 integer", () => { + assert.equal(parseFreeMiB("5001\r\n"), 5001); + assert.equal(parseFreeMiB("0"), 0); + assert.equal(parseFreeMiB(" 12 \n7\n"), 12); + for (const bad of ["", "[N/A]", "abc", "-5", "1e3", "12.5", "12 MiB", "\n5", null, undefined]) { + assert.equal(parseFreeMiB(bad), null, JSON.stringify(bad)); + } }); test("placement refusal is keyed on the typed code, never on text, and carries no gate status", () => { diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index a2ef9b5..51282a7 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { execFile } from "node:child_process"; import { WebSocketServer } from "ws"; -import { RETRY_AFTER_SECONDS, admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, placementRefusal } from "./gpu-tenancy.mjs"; +import { RETRY_AFTER_SECONDS, admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, parseFreeMiB, placementRefusal } from "./gpu-tenancy.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -63,6 +63,19 @@ const operatorStateDefinition = defineDocumentType({ { slot: 4, memberName: "gpu", typeName: "object" }, ], }); +const gpuPermissionDefinition = defineDocumentType({ + type: "gamecult.vili.gpu_permission", + schemaName: "gamecult.vili.gpu_permission", + schemaId: "gamecult.vili.gpu_permission.v1", + schemaVersion: "gamecult.vili.gpu_permission.v1", + name: (value) => value?.host || "raven", + schema: objectSchema, + members: [ + { slot: 0, memberName: "host", typeName: "string", isName: true }, + { slot: 1, memberName: "allowed", typeName: "boolean" }, + { slot: 2, memberName: "changedAt", typeName: "string" }, + ], +}); const eveSurfaceDefinition = defineDocumentType({ type: "gamecult.eve.surface_state", schemaName: "gamecult.eve.surface_state", @@ -110,6 +123,7 @@ const transportProfileDefinition = defineDocumentType({ const viliDocumentRegistry = defineDocumentRegistry( providerAdvertisementDefinition, operatorStateDefinition, + gpuPermissionDefinition, eveSurfaceDefinition, commandBoundaryDefinition, transportProfileDefinition, @@ -138,7 +152,23 @@ function parseArgs(argv) { const args = parseArgs(process.argv.slice(2)); const startedAt = new Date().toISOString(); const gpuOptions = gpuTenancyOptionsFromEnv(process.env); -const gpuTenancy = createGpuTenancy(gpuOptions); +// The daemon's one cache and store instance: every write to vili.service.cc goes through one queue. +const viliCache = CultCache.builder() + .withRegistry(viliDocumentRegistry) + .withGenericStore(new SingleFileMessagePackBackingStore(path.join(args.stateRoot, viliStoreFileName))) + .build(); +let storedPermission; +if (!args.health && !args.printProviderAdvertisement) { + await viliCache.pullAllBackingStores(); + storedPermission = viliCache.get(gpuPermissionDefinition, "raven"); +} +const gpuTenancy = createGpuTenancy({ + now: Date.now, + ...gpuOptions, + permission: storedPermission, + savePermission: (value) => viliCache.put(gpuPermissionDefinition, "raven", { host: "raven", ...value }), + readFreeMiB: hostFreeMiB, +}); let backendStatus = { checkedAt: null, dockerImage: "unknown", @@ -175,6 +205,11 @@ function wsl(script, options = {}) { return run("wsl.exe", ["-d", "Ubuntu-24.04", "-u", "root", "--", "bash", "-lc", script], options); } +async function hostFreeMiB() { + const r = await run("nvidia-smi.exe", ["--query-gpu=memory.free", "--format=csv,noheader,nounits"], { timeout: 5000 }); + return r.ok ? parseFreeMiB(r.stdout) : null; +} + function shQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } @@ -281,6 +316,7 @@ function operatorState() { deckProviders: "/eve/deck/providers", smoke: "/smoke", generate: "/motion/generate", + gpuPermission: "/gpu-permission", }, }; } @@ -304,6 +340,11 @@ function commandBoundary() { "motion artifact references", ], }, + gpuPermission: { + method: "POST", + path: "/gpu-permission", + owns: ["the household GPU permission for this host, from loopback only"], + }, smoke: { method: "GET", path: "/smoke", @@ -383,15 +424,11 @@ async function persistSurfaces() { } async function persistCultCacheSurfaces() { - const cache = CultCache.builder() - .withRegistry(viliDocumentRegistry) - .withGenericStore(new SingleFileMessagePackBackingStore(path.join(args.stateRoot, viliStoreFileName))) - .build(); - await cache.put(providerAdvertisementDefinition, providerId, providerAdvertisement()); - await cache.put(operatorStateDefinition, "vili", operatorState()); - await cache.put(eveSurfaceDefinition, providerId, eveSurface()); - await cache.put(commandBoundaryDefinition, "vili", commandBoundary()); - await cache.put(transportProfileDefinition, "vili", transportProfile()); + await viliCache.put(providerAdvertisementDefinition, providerId, providerAdvertisement()); + await viliCache.put(operatorStateDefinition, "vili", operatorState()); + await viliCache.put(eveSurfaceDefinition, providerId, eveSurface()); + await viliCache.put(commandBoundaryDefinition, "vili", commandBoundary()); + await viliCache.put(transportProfileDefinition, "vili", transportProfile()); } async function refreshBackendStatus({ includeKimodoHelp = false } = {}) { @@ -508,6 +545,28 @@ async function readRequestBody(request) { return raw ? JSON.parse(raw) : {}; } +const loopbackAddresses = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); + +// The household's switch: only a caller on this machine (the tray, in the household's session) sets it. +async function setGpuPermission(request, response) { + if (!loopbackAddresses.has(request.socket.remoteAddress)) { + json(response, 403, { ok: false, code: "household-only" }); + return; + } + let body; + try { + body = await readRequestBody(request); + } catch (error) { + json(response, 400, { ok: false, error: `Invalid JSON: ${error.message}` }); + return; + } + if (typeof body?.allowed !== "boolean") { + json(response, 400, { ok: false, error: "allowed must be a boolean." }); + return; + } + json(response, 200, { ok: true, gpu: await gpuTenancy.setAllowed(body.allowed) }); +} + async function generateMotion(request, response) { let body; try { @@ -704,6 +763,10 @@ async function handleRequest(request, response) { await refreshBackendStatus({ includeKimodoHelp: false }); const ok = result.backendStatus.kimodoHelp === "ok" && worker.ok && worker.body?.ok; json(response, ok ? 200 : 502, { ok, ...result, worker: worker.body || worker }); + } else if (url.pathname === "/gpu-permission" && request.method === "GET") { + json(response, 200, gpuTenancy.status()); + } else if (url.pathname === "/gpu-permission" && request.method === "POST") { + await setGpuPermission(request, response); } else if (url.pathname === "/motion/generate" && request.method === "POST") { await generateMotion(request, response); } else { From 375c2faf4e2d0282fb1cf50e771e122177b32b94 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 01:45:44 +0200 Subject: [PATCH 17/26] Add the household's tray toggle and its logon task vili-gpu-tray.ps1 is one NotifyIcon in the interactive session: it lowers GET /gpu-permission to an icon and tooltip and POSTs an absolute value on click. It holds no state, reads no process list and writes no file. install-vili-tray-task.ps1 registers only GameCult\ViliTray at logon. Co-Authored-By: Claude Opus 5.5 --- scripts/install-vili-tray-task.ps1 | 17 ++++++ scripts/vili-gpu-tray.ps1 | 98 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 scripts/install-vili-tray-task.ps1 create mode 100644 scripts/vili-gpu-tray.ps1 diff --git a/scripts/install-vili-tray-task.ps1 b/scripts/install-vili-tray-task.ps1 new file mode 100644 index 0000000..0dfb881 --- /dev/null +++ b/scripts/install-vili-tray-task.ps1 @@ -0,0 +1,17 @@ +param( + [string]$TaskName = "GameCult\ViliTray", + [int]$Port = 8824 +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +$HiddenLauncher = Join-Path $PSScriptRoot "run-hidden-powershell.vbs" +$TrayScript = Join-Path $PSScriptRoot "vili-gpu-tray.ps1" +$UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$Action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "//B //nologo `"$HiddenLauncher`" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -STA -File `"$TrayScript`" -Port $Port" +$Trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId +$Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited +$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -Hidden + +Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force | Out-Null +Write-Host "Installed scheduled task $TaskName" diff --git a/scripts/vili-gpu-tray.ps1 b/scripts/vili-gpu-tray.ps1 new file mode 100644 index 0000000..b5e9e86 --- /dev/null +++ b/scripts/vili-gpu-tray.ps1 @@ -0,0 +1,98 @@ +# The household's switch for Raven's GPU: one tray icon in the interactive session. It shows whether +# Vili may use the GPU and flips it with one click. It holds no state of its own: the icon and tooltip +# are a lowering of GET /gpu-permission, and the click is one POST with an absolute value. +param([int]$Port = 8824, [int]$PollMs = 5000) + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$mutex = New-Object System.Threading.Mutex($false, 'Local\GameCult.ViliGpuTray') +if (-not $mutex.WaitOne(0)) { exit 0 } + +$base = "http://127.0.0.1:$Port/gpu-permission" +$script:state = 'down' +$script:jobRunning = $false +$clock = [Diagnostics.Stopwatch]::StartNew() +$script:lastToggleMs = -10000 + +function New-TrayIcon([string]$kind) { + $bmp = New-Object System.Drawing.Bitmap 16, 16 + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = 'AntiAlias' + if ($kind -eq 'on') { + $g.FillEllipse([System.Drawing.Brushes]::LimeGreen, 1, 1, 13, 13) + } elseif ($kind -eq 'off') { + $g.FillEllipse([System.Drawing.Brushes]::Crimson, 1, 1, 13, 13) + $g.FillRectangle([System.Drawing.Brushes]::White, 4, 7, 8, 2) + } else { + $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::Gray), 2 + $g.DrawEllipse($pen, 2, 2, 11, 11) + $pen.Dispose() + } + $g.Dispose() + $icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon()) + return $icon +} + +$icons = @{ on = (New-TrayIcon 'on'); off = (New-TrayIcon 'off'); down = (New-TrayIcon 'down') } + +$tray = New-Object System.Windows.Forms.NotifyIcon +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$item = $menu.Items.Add('Let Vili use the GPU') +$tray.ContextMenuStrip = $menu + +function Show-State { + $tray.Icon = $icons[$script:state] + $item.Checked = ($script:state -eq 'on') + $item.Enabled = ($script:state -ne 'down') + if ($script:state -eq 'on' -and $script:jobRunning) { $tray.Text = 'Vili may use the GPU (making a clip). Click to stop.' } + elseif ($script:state -eq 'on') { $tray.Text = 'Vili may use the GPU. Click to stop.' } + elseif ($script:state -eq 'off') { $tray.Text = 'Vili may not use the GPU. Click to allow.' } + else { $tray.Text = 'Vili is not running, so it is not using the GPU.' } +} + +function Update-State { + try { + $r = Invoke-RestMethod -Uri $base -Method Get -TimeoutSec 2 + if ($r.allowed -eq $true) { $script:state = 'on' } elseif ($r.allowed -eq $false) { $script:state = 'off' } else { $script:state = 'down' } + $script:jobRunning = ($r.jobRunning -eq $true) + } catch { + $script:state = 'down' + $script:jobRunning = $false + } + Show-State +} + +function Switch-Permission { + try { + if ($clock.ElapsedMilliseconds - $script:lastToggleMs -lt 1000) { return } + $script:lastToggleMs = $clock.ElapsedMilliseconds + if ($script:state -eq 'down') { + $tray.ShowBalloonTip(3000, 'Vili', 'Vili is not running. Nothing to change.', 'Info') + return + } + $body = ConvertTo-Json -Compress @{ allowed = ($script:state -ne 'on') } + try { + [void](Invoke-RestMethod -Uri $base -Method Post -Body $body -ContentType 'application/json' -TimeoutSec 2) + } catch { + $tray.ShowBalloonTip(3000, 'Vili', 'Could not reach Vili. Nothing changed.', 'Warning') + } + Update-State + } catch { + } +} + +$tray.Add_MouseUp({ + param($sender, $e) + try { if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Switch-Permission } } catch {} +}) +$item.Add_Click({ try { Switch-Permission } catch {} }) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = $PollMs +$timer.Add_Tick({ try { Update-State } catch {} }) + +Update-State +$tray.Visible = $true +$timer.Start() +[System.Windows.Forms.Application]::Run() From 455cedb3c379ff582f3cd965f3fed6110f5f2112 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 01:49:01 +0200 Subject: [PATCH 18/26] Mutation triage: drop a redundant null guard in parseFreeMiB String(null) and String(undefined) already fail the integer test, so the ?? "" guard decided nothing; the Stryker mutant of it survived. Co-Authored-By: Claude Opus 5.5 --- scripts/gpu-tenancy.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gpu-tenancy.mjs b/scripts/gpu-tenancy.mjs index e26f9b6..b726854 100644 --- a/scripts/gpu-tenancy.mjs +++ b/scripts/gpu-tenancy.mjs @@ -73,7 +73,7 @@ export async function admitThen(gate, job) { // nvidia-smi's first line is the free MiB as a base-10 integer; anything else is null. export function parseFreeMiB(stdout) { - const line = String(stdout ?? "").split("\n")[0].trim(); + const line = String(stdout).split("\n")[0].trim(); return /^\d+$/.test(line) ? Number(line) : null; } From 5a60255ac2e32223a49dd43a8220f15450e43ba3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 02:26:04 +0200 Subject: [PATCH 19/26] Make the slot's release idempotent so a stale second release cannot free another job's slot Co-Authored-By: Claude Opus 5.5 --- scripts/gpu-tenancy.mjs | 3 +++ scripts/test-gpu-tenancy.mjs | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/scripts/gpu-tenancy.mjs b/scripts/gpu-tenancy.mjs index b726854..7eabccd 100644 --- a/scripts/gpu-tenancy.mjs +++ b/scripts/gpu-tenancy.mjs @@ -49,8 +49,11 @@ export function createGpuTenancy({ now, permission, savePermission, readFreeMiB, held = false; return refuse("vram", { freeMiB: Number.isFinite(freeMiB) ? freeMiB : null, minFreeMiB }); } + let released = false; return { release() { + if (released) return; + released = true; held = false; }, }; diff --git a/scripts/test-gpu-tenancy.mjs b/scripts/test-gpu-tenancy.mjs index 2d192c7..f2ba3e7 100644 --- a/scripts/test-gpu-tenancy.mjs +++ b/scripts/test-gpu-tenancy.mjs @@ -156,6 +156,21 @@ test("gate: setAllowed saves before it answers", async () => { assert.equal(same.changedAt, "2026-10-23T10:00:01.000Z", "and keeps changedAt"); }); +test("gate: a second release is a no-op and cannot free another job's slot", async () => { + const r = rig({ permission: ON }); + const first = await r.gate.acquire(); + first.release(); + const second = await r.gate.acquire(); + assert.equal(r.gate.status().jobRunning, true); + first.release(); + assert.equal(r.gate.status().jobRunning, true, "a stale release leaves the new holder's slot held"); + assert.equal((await r.gate.acquire()).refusal.source, "slot"); + second.release(); + second.release(); + assert.equal(r.gate.status().jobRunning, false); + assert.equal(typeof (await r.gate.acquire()).release, "function", "the slot is free after the real release"); +}); + test("gate: a stored permission is the starting state", () => { assert.deepEqual(rig({ permission: ON }).gate.status(), { allowed: true, changedAt: ON.changedAt, jobRunning: false, lastRefusal: null }); assert.equal(rig({ permission: { allowed: "yes", changedAt: "x" } }).gate.status().allowed, false, "only a true boolean allows"); From cb36beb09a68e4c573877ff729610afc911b433d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 02:29:43 +0200 Subject: [PATCH 20/26] Refuse cross-site POSTs to the state-changing routes and grant no CORS preflight POST /gpu-permission and POST /motion/generate now require Content-Type application/json and refuse a request whose Origin is present and not the daemon's own loopback origin, 403 cross-site, before the body is read. The blanket OPTIONS handler that approved any preflight is deleted. Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-gate-http.mjs | 42 ++++++++++++++++++++++++++++++++++ scripts/vili-daemon.mjs | 24 +++++++++++-------- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 9fe62a2..0fc1481 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -144,6 +144,48 @@ test("http: the switch is the household's", async () => { } }); +test("http: a web page cannot flip the household's switch or start a job", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-csrf-")); + const d = await bootDaemon(stateRoot); + const post = (route, headers, body) => fetch(url(d, route), { method: "POST", headers, body }); + const prompt = JSON.stringify({ prompt: "a person waves hello, never seen before" }); + try { + const before = await readPermission(d); + for (const route of ["/gpu-permission", "/motion/generate"]) { + const payload = route === "/gpu-permission" ? JSON.stringify({ allowed: true }) : prompt; + const simple = await post(route, { "content-type": "text/plain" }, payload); + assert.equal(simple.status, 403, `${route}: a simple text/plain POST`); + assert.deepEqual(await simple.json(), { ok: false, code: "cross-site" }); + assert.equal((await post(route, {}, payload)).status, 403, `${route}: no content-type`); + const hostile = await post(route, { "content-type": "application/json", origin: "http://evil.example-origin" }, payload); + assert.equal(hostile.status, 403, `${route}: a hostile Origin`); + const body = JSON.stringify(await hostile.json()); + assert.equal(body.includes("evil.example-origin"), false, "the refusal never echoes the Origin"); + assert.equal( + (await post(route, { "content-type": "application/json", origin: `http://127.0.0.1:${d.port}.evil.example` }, payload)).status, + 403, + `${route}: an Origin that only starts like ours`, + ); + } + assert.deepEqual(await readPermission(d), before, "no refused request changed anything"); + assert.deepEqual(await readdir(path.join(stateRoot, "jobs")), [], "no job file"); + + const preflight = await fetch(url(d, "/gpu-permission"), { + method: "OPTIONS", + headers: { origin: "http://evil.example-origin", "access-control-request-method": "POST", "access-control-request-headers": "content-type" }, + }); + assert.equal(preflight.ok, false, "the daemon approves no CORS preflight"); + assert.equal(preflight.headers.get("access-control-allow-methods"), null); + + const own = await post("/gpu-permission", { "content-type": "application/json; charset=utf-8", origin: `http://127.0.0.1:${d.port}` }, JSON.stringify({ allowed: true })); + assert.equal(own.status, 200, "the daemon's own origin still works"); + assert.equal((await setPermission(d, { allowed: false })).status, 200, "a caller with no Origin (the tray) still works"); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); + test("http: a malformed override refuses to start and names the variable, not its value", async () => { const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-env-")); const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(nextPort++), "--state-root", stateRoot], { diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index 51282a7..5f057b3 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -547,8 +547,21 @@ async function readRequestBody(request) { const loopbackAddresses = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); +// A state-changing POST is a service's or the tray's call, never a web page's. A browser sends a +// cross-site page's POST as a no-preflight simple request (text/plain) or with an Origin that is not +// ours, so both are refused before the body is read. The daemon grants no CORS preflight at all. +function refuseCrossSite(request, response) { + const origin = request.headers.origin; + const ownOrigins = [`http://127.0.0.1:${args.port}`, `http://localhost:${args.port}`, `http://[::1]:${args.port}`]; + const type = String(request.headers["content-type"] || "").split(";")[0].trim().toLowerCase(); + if (type === "application/json" && (origin === undefined || ownOrigins.includes(origin))) return false; + json(response, 403, { ok: false, code: "cross-site" }); + return true; +} + // The household's switch: only a caller on this machine (the tray, in the household's session) sets it. async function setGpuPermission(request, response) { + if (refuseCrossSite(request, response)) return; if (!loopbackAddresses.has(request.socket.remoteAddress)) { json(response, 403, { ok: false, code: "household-only" }); return; @@ -568,6 +581,7 @@ async function setGpuPermission(request, response) { } async function generateMotion(request, response) { + if (refuseCrossSite(request, response)) return; let body; try { body = await readRequestBody(request); @@ -729,16 +743,6 @@ async function runMotionJob(body, prompt, response) { async function handleRequest(request, response) { const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); - if (request.method === "OPTIONS") { - response.writeHead(204, { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET,POST,OPTIONS", - "access-control-allow-headers": "content-type", - }); - response.end(); - return; - } - if (url.pathname === "/" || url.pathname === "/health") { await refreshBackendStatus({ includeKimodoHelp: false }); json(response, 200, { ok: true, service: "vili", providerId, state: operatorState() }); From e37a2ad3ec17b853e2704ffa25797277909967db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 02:29:49 +0200 Subject: [PATCH 21/26] Pin the daemon's slot hold at the HTTP edge with a stubbed slow worker A real daemon on stub wsl.exe and nvidia-smi.exe runs one job held open by the test; requests during it are 503 source slot, the slot frees at the job's end, and the next job is admitted. Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-gate-http.mjs | 95 +++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 0fc1481..472b0cb 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -3,8 +3,9 @@ // sets VILI_GPU_MIN_FREE_MIB=999999 so an allowed request ends in a VRAM refusal and never starts // the worker: no test here may start a GPU job. import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { copyFile, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -186,6 +187,96 @@ test("http: a web page cannot flip the household's switch or start a job", async } }); +// The daemon reaches the GPU only through the processes wsl.exe and nvidia-smi.exe. This test puts +// one small compiled stub on PATH under both names (Windows, where the daemon lives), so the real +// daemon runs a real job against a worker that takes as long as the test says and touches no GPU: +// nvidia-smi.exe answers 20000 free MiB, the worker health call answers ok, and the "/generate" +// call waits until the test creates a release file. +const stubSource = ` +using System; using System.IO; using System.Reflection; using System.Threading; +class Stub { + static int Main(string[] args) { + string exe = Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly().Location).ToLowerInvariant(); + string dir = Environment.GetEnvironmentVariable("VILI_STUB_DIR"); + if (exe == "nvidia-smi") { Console.WriteLine("20000"); return 0; } + string script = args.Length > 0 ? args[args.Length - 1] : ""; + if (script.Contains("/health")) { Console.WriteLine("{\\"ok\\":true,\\"model\\":\\"stub\\"}"); return 0; } + if (script.Contains("/generate")) { + File.AppendAllText(Path.Combine(dir, "started"), "x"); + while (!File.Exists(Path.Combine(dir, "release"))) Thread.Sleep(25); + Console.WriteLine("{\\"ok\\":true}"); + } + return 0; + } +}`; + +async function stubbedDaemon(stubDir, stateRoot) { + const source = path.join(stubDir, "stub.cs"); + await writeFile(source, stubSource); + const build = path.join(stubDir, "build.ps1"); + await writeFile(build, [ + "param($Source, $Out)", + "Add-Type -TypeDefinition (Get-Content -Raw $Source) -OutputAssembly $Out -OutputType ConsoleApplication", + ].join("\r\n")); + const built = spawnSync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", build, source, path.join(stubDir, "wsl.exe"), + ], { encoding: "utf8", windowsHide: true }); + assert.equal(built.status, 0, `the stub did not compile: ${built.stderr || built.stdout}`); + await copyFile(path.join(stubDir, "wsl.exe"), path.join(stubDir, "nvidia-smi.exe")); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + return bootDaemon(stateRoot, { + env: { + [pathKey]: `${stubDir}${path.delimiter}${process.env[pathKey]}`, + VILI_STUB_DIR: stubDir, + VILI_GPU_MIN_FREE_MIB: "1", + }, + }); +} + +test("http: the daemon holds the slot for the whole job", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-hold-")); + const stubDir = await mkdtemp(path.join(os.tmpdir(), "vili-gate-stub-")); + const d = await stubbedDaemon(stubDir, stateRoot); + const prompt = { prompt: "a person waves hello, never seen before" }; + const within = (promise, ms, what) => Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} did not answer within ${ms} ms`)), ms)), + ]); + try { + await setPermission(d, { allowed: true }); + const first = generate(d, prompt); + const deadline = Date.now() + 20000; + while (!(await readPermission(d)).jobRunning) { + assert.ok(Date.now() < deadline, "the first job never took the slot"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + while (!existsSync(path.join(stubDir, "started"))) { + assert.ok(Date.now() < deadline, "the first job never reached the worker"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + for (let i = 0; i < 3; i += 1) { + const second = await within(generate(d, prompt), 5000, "a request during the job"); + assert.equal(second.status, 503, "a request during the job is refused at once"); + assert.deepEqual(await second.json(), { ok: false, code: "gpu-busy", source: "slot" }); + } + assert.equal(readFileSync(path.join(stubDir, "started"), "utf8"), "x", "exactly one job reached the worker"); + assert.equal((await readPermission(d)).jobRunning, true, "the slot is still held"); + + await writeFile(path.join(stubDir, "release"), ""); + const done = await within(first, 20000, "the first job"); + assert.equal(done.status, 200); + assert.equal((await readPermission(d)).jobRunning, false, "the slot frees when the job ends"); + const next = await within(generate(d, prompt), 20000, "the next job"); + assert.equal(next.status, 200, "the next job is admitted after the slot frees"); + assert.equal(readFileSync(path.join(stubDir, "started"), "utf8"), "xx"); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + await rm(stubDir, { recursive: true, force: true }); + } +}); + test("http: a malformed override refuses to start and names the variable, not its value", async () => { const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-env-")); const child = spawn(process.execPath, [daemonPath, "--host", "127.0.0.1", "--port", String(nextPort++), "--state-root", stateRoot], { From a3052ddf63bcb53dc880793879b41562c60156c1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 02:30:07 +0200 Subject: [PATCH 22/26] Contract: the POST routes refuse cross-site requests Co-Authored-By: Claude Opus 5.5 --- docs/verse-service-contract.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index ad2cbb1..9f6fd09 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -57,7 +57,9 @@ unreadable (`freeMiB` null); and `{ "ok": false, "code": "gpu-busy", "source": " household status is claimed. `GET /gpu-permission` answers `{ allowed, changedAt, jobRunning, lastRefusal }` to anyone; `POST /gpu-permission { "allowed": boolean }` is the household's switch and answers 403 `{ "ok": false, "code": "household-only" }` to any caller that is not on the host's -loopback. The `code` field is the only carrier; a consumer retries after `retry-after`, never sets +loopback. Both POST routes require `Content-Type: application/json` and refuse a request whose +`Origin` header is present and not the daemon's own loopback origin with 403 `{ "ok": false, "code": +"cross-site" }`, so a web page cannot drive them; the daemon answers no CORS preflight. The `code` field is the only carrier; a consumer retries after `retry-after`, never sets the permission, and never matches error text. A job already running finishes. ```json From 3fa19d75903b74913a05b961bd2eb587fa247e70 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 03:09:05 +0200 Subject: [PATCH 23/26] Delete GET /smoke, make GET read-only, listen on loopback and WireGuard only GET /smoke ran docker run --gpus all and started the resident worker outside the gate, reachable by any page or LAN host while the household's switch was off. Its only callers were scripts/smoke-vili.mjs and the Eve Smoke button, so the route, the script, the button and the advertised endpoints go. refreshBackendStatus lost its includeKimodoHelp GPU branch and every service docker start: a GET now only asks. The worker starts only inside a job that holds the slot. --host takes the addresses to listen on, one server each (Raven: 127.0.0.1,10.77.0.4); a wildcard refuses to start. Callers are the tray and health (loopback) and WireGuard peers (Starfire, services). Co-Authored-By: Claude Opus 5.5 --- scripts/smoke-vili.mjs | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 scripts/smoke-vili.mjs diff --git a/scripts/smoke-vili.mjs b/scripts/smoke-vili.mjs deleted file mode 100644 index 30cc401..0000000 --- a/scripts/smoke-vili.mjs +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -const baseUrl = process.env.VILI_URL || process.argv[2] || "http://10.77.0.4:8824"; - -async function get(path) { - const response = await fetch(`${baseUrl}${path}`); - const text = await response.text(); - let body; - try { - body = JSON.parse(text); - } catch { - body = text; - } - if (!response.ok) { - throw new Error(`${path} returned ${response.status}: ${text.slice(0, 500)}`); - } - return body; -} - -const health = await get("/health"); -const providers = await get("/eve/deck/providers"); -const smoke = await get("/smoke"); - -console.log(JSON.stringify({ - ok: true, - baseUrl, - health: { - service: health.service, - providerId: health.providerId, - backend: health.state?.backend, - }, - providers, - smoke: { - ok: smoke.ok, - backend: smoke.backendStatus, - }, -}, null, 2)); From 9e79b00ce11866572629986405c14bf6aa119026 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 03:09:05 +0200 Subject: [PATCH 24/26] Drop access-control-allow-origin: * from every response No browser caller exists, so no page needs to read the daemon. The wildcard let any page read /gpu-permission and /operator-state. Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-gate-http.mjs | 82 ++++++++++++++++++++++++- scripts/vili-daemon.mjs | 105 +++++++++++---------------------- 2 files changed, 115 insertions(+), 72 deletions(-) diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 472b0cb..599d08e 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -129,16 +129,21 @@ test("http: an allowed request reaches the slot and the VRAM read", async () => test("http: the switch is the household's", async () => { const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-lan-")); - const d = await bootDaemon(stateRoot, { host: "0.0.0.0" }); + const addresses = Object.values(os.networkInterfaces()).flat().filter((i) => i.family === "IPv4" && !i.internal).map((i) => i.address); + const lan = addresses[0]; + assert.ok(lan, "the host has a non-loopback IPv4 address"); + const d = await bootDaemon(stateRoot, { host: `127.0.0.1,${lan}` }); try { - const lan = Object.values(os.networkInterfaces()).flat().find((i) => i.family === "IPv4" && !i.internal)?.address; - assert.ok(lan, "the host has a non-loopback IPv4 address"); const before = await readPermission(d); const refused = await setPermission(d, { allowed: true }, lan); assert.equal(refused.status, 403); assert.deepEqual(await refused.json(), { ok: false, code: "household-only" }); assert.deepEqual(await readPermission(d), before, "GET still reads the previous value"); assert.equal((await setPermission(d, { allowed: true })).status, 200, "loopback still sets it"); + const other = addresses.find((address) => address !== lan); + if (other) { + await assert.rejects(fetch(url(d, "/gpu-permission", other)), "an address --host does not name is not served"); + } } finally { await d.stop(); await rm(stateRoot, { recursive: true, force: true }); @@ -200,6 +205,8 @@ class Stub { string dir = Environment.GetEnvironmentVariable("VILI_STUB_DIR"); if (exe == "nvidia-smi") { Console.WriteLine("20000"); return 0; } string script = args.Length > 0 ? args[args.Length - 1] : ""; + if (script.Contains("docker run")) File.AppendAllText(Path.Combine(dir, "dockerrun"), "x"); + if (script.Contains("service docker start")) File.AppendAllText(Path.Combine(dir, "dockerstart"), "x"); if (script.Contains("/health")) { Console.WriteLine("{\\"ok\\":true,\\"model\\":\\"stub\\"}"); return 0; } if (script.Contains("/generate")) { File.AppendAllText(Path.Combine(dir, "started"), "x"); @@ -298,3 +305,72 @@ test("http: a malformed override refuses to start and names the variable, not it await rm(stateRoot, { recursive: true, force: true }); } }); + +test("http: the daemon refuses to listen on a wildcard address", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-wild-")); + try { + for (const host of ["0.0.0.0", "127.0.0.1,0.0.0.0", "::"]) { + const run = spawnSync(process.execPath, [daemonPath, "--host", host, "--port", String(nextPort++), "--state-root", stateRoot], { + encoding: "utf8", + windowsHide: true, + timeout: 20000, + }); + assert.equal(run.status, 2, `--host ${host} exits 2`); + assert.equal(`${run.stdout}${run.stderr}`.includes("Vili listening"), false); + } + } finally { + await rm(stateRoot, { recursive: true, force: true }); + } +}); + +test("http: no response grants a web page access", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-cors-")); + const d = await bootDaemon(stateRoot); + try { + const hostile = { origin: "http://evil.example-origin" }; + const responses = [ + await fetch(url(d, "/gpu-permission"), { headers: hostile }), + await fetch(url(d, "/operator-state"), { headers: hostile }), + await fetch(url(d, "/nowhere"), { headers: hostile }), + await fetch(url(d, "/gpu-permission"), { method: "POST", headers: { ...hostile, "content-type": "application/json" }, body: "{}" }), + ]; + assert.deepEqual(responses.map((r) => r.status), [200, 200, 404, 403]); + for (const response of responses) { + const cors = [...response.headers.keys()].filter((name) => name.startsWith("access-control-")); + assert.deepEqual(cors, [], "no access-control header on any response"); + } + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + } +}); + +test("http: with the permission off nothing a web page or a LAN caller sends reaches the GPU", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-off-")); + const stubDir = await mkdtemp(path.join(os.tmpdir(), "vili-gate-stub-")); + const d = await stubbedDaemon(stubDir, stateRoot); + const page = { origin: "http://evil.example-origin", "sec-fetch-site": "cross-site", "sec-fetch-mode": "no-cors", "sec-fetch-dest": "image" }; + try { + const before = await readPermission(d); + assert.equal(before.allowed, false); + for (const route of ["/smoke", "/health", "/", "/operator-state", "/eve/operator", "/provider-advertisement", "/eve/deck/providers", "/gpu-permission"]) { + const response = await fetch(url(d, route), { headers: page }); + assert.equal(response.status, route === "/smoke" ? 404 : 200, `GET ${route}`); + } + const refused = await generate(d, { prompt: "a person waves hello, never seen before" }); + assert.equal(refused.status, 503); + assert.equal((await refused.json()).source, "household"); + assert.equal((await setPermission(d, { allowed: true }, "127.0.0.1")).status, 200); + assert.equal((await setPermission(d, { allowed: false }, "127.0.0.1")).status, 200); + assert.deepEqual((await readPermission(d)).allowed, false); + assert.equal(existsSync(path.join(stubDir, "dockerrun")), false, "nothing ran docker run (the worker or any GPU container)"); + assert.equal(existsSync(path.join(stubDir, "dockerstart")), false, "no GET started Docker"); + assert.equal(existsSync(path.join(stubDir, "started")), false, "nothing reached the worker"); + assert.deepEqual(await readdir(path.join(stateRoot, "jobs")), [], "no job file"); + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + await rm(stubDir, { recursive: true, force: true }); + } +}); + diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index 5f057b3..a8fc87d 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -131,6 +131,8 @@ const viliDocumentRegistry = defineDocumentRegistry( function parseArgs(argv) { const args = { + // Comma-separated addresses to listen on, one server each. Never 0.0.0.0: the daemon answers + // this machine and the WireGuard address, not whatever network the machine joins next. host: process.env.VILI_DAEMON_HOST || "127.0.0.1", port: Number(process.env.VILI_DAEMON_PORT || 8824), stateRoot: process.env.VILI_STATE_ROOT || path.join(projectRoot, ".vili"), @@ -150,6 +152,7 @@ function parseArgs(argv) { } const args = parseArgs(process.argv.slice(2)); +const hosts = args.host.split(",").map((host) => host.trim()).filter(Boolean); const startedAt = new Date().toISOString(); const gpuOptions = gpuTenancyOptionsFromEnv(process.env); // The daemon's one cache and store instance: every write to vili.service.cc goes through one queue. @@ -174,7 +177,6 @@ let backendStatus = { dockerImage: "unknown", nvidiaRuntime: "unknown", huggingFaceToken: "unknown", - kimodoHelp: "unknown", kimodoWorker: "unknown", kimodoWorkerDetail: null, lastError: null, @@ -238,7 +240,6 @@ function json(response, statusCode, body, headers = {}) { ...headers, "content-type": "application/json; charset=utf-8", "cache-control": "no-store", - "access-control-allow-origin": "*", }); response.end(payload); } @@ -247,7 +248,6 @@ function text(response, statusCode, body) { response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", - "access-control-allow-origin": "*", }); response.end(body); } @@ -266,7 +266,6 @@ function providerAdvertisement() { endpoints: { http: ravenBaseUrl, health: `${ravenBaseUrl}/health`, - smoke: `${ravenBaseUrl}/smoke`, providerAdvertisement: `${ravenBaseUrl}/provider-advertisement`, operatorState: `${ravenBaseUrl}/operator-state`, operatorSurface: `${ravenBaseUrl}/eve/operator`, @@ -314,7 +313,6 @@ function operatorState() { operatorState: "/operator-state", operatorSurface: "/eve/operator", deckProviders: "/eve/deck/providers", - smoke: "/smoke", generate: "/motion/generate", gpuPermission: "/gpu-permission", }, @@ -345,11 +343,6 @@ function commandBoundary() { path: "/gpu-permission", owns: ["the household GPU permission for this host, from loopback only"], }, - smoke: { - method: "GET", - path: "/smoke", - owns: ["backend readiness proof", "resident worker warmup"], - }, liveness: { method: "GET", path: "/health", @@ -400,10 +393,8 @@ function eveSurface() { { type: "stat", label: "Verse", value: "raven.local" }, { type: "stat", label: "Docker image", value: state.backend.dockerImage }, { type: "stat", label: "NVIDIA runtime", value: state.backend.nvidiaRuntime }, - { type: "stat", label: "Kimodo", value: state.backend.kimodoHelp }, { type: "stat", label: "Kimodo worker", value: state.backend.kimodoWorker }, { type: "stat", label: "HF token", value: state.backend.huggingFaceToken }, - { type: "command", label: "Smoke", method: "GET", href: `${ravenBaseUrl}/smoke` }, { type: "command", label: "Generate motion", method: "POST", href: `${ravenBaseUrl}/motion/generate` }, ], }, @@ -431,48 +422,24 @@ async function persistCultCacheSurfaces() { await viliCache.put(transportProfileDefinition, "vili", transportProfile()); } -async function refreshBackendStatus({ includeKimodoHelp = false } = {}) { - const docker = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - "docker images gamecult/kimodo:latest --format '{{.Repository}}:{{.Tag}}' | head -n 1", - ].join("; "), { timeout: 20000 }); - - let nvidia = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - "docker info --format '{{json .Runtimes}}' | grep -q nvidia && echo configured || echo missing", - ].join("; "), { timeout: 20000 }); - +// A read: it only asks. Starting docker and the worker belongs to a job that holds the GPU slot. +async function refreshBackendStatus() { + const docker = await wsl("docker images gamecult/kimodo:latest --format '{{.Repository}}:{{.Tag}}' | head -n 1", { timeout: 20000 }); + const nvidia = await wsl("docker info --format '{{json .Runtimes}}' | grep -q nvidia && echo configured || echo missing", { timeout: 20000 }); const token = await wsl("test -f /root/.cache/huggingface/token && echo present || echo missing", { timeout: 10000 }); const worker = await kimodoWorkerHealth(20000); - let help = { ok: true, stdout: backendStatus.kimodoHelp === "ok" ? "ok" : "not checked", stderr: "" }; - if (includeKimodoHelp) { - nvidia = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - "docker run --rm --runtime=nvidia --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi --query-gpu=name --format=csv,noheader | head -n 1", - ].join("; "), { timeout: 45000 }); - help = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - dockerHuggingFaceEnv, - `docker run --rm --runtime=nvidia --gpus all ${dockerHuggingFaceArgs} gamecult/kimodo:latest kimodo_gen --help | sed -n '1,6p'`, - ].join("; "), { timeout: 60000 }); - } backendStatus = { checkedAt: new Date().toISOString(), dockerImage: docker.ok && docker.stdout ? docker.stdout : "missing", nvidiaRuntime: nvidia.ok && nvidia.stdout ? nvidia.stdout : "unavailable", huggingFaceToken: token.ok ? token.stdout : "unknown", - kimodoHelp: includeKimodoHelp ? (help.ok ? "ok" : "failed") : backendStatus.kimodoHelp, kimodoWorker: worker.ok && worker.body?.ok ? "resident" : "not running", kimodoWorkerDetail: worker.body || null, - lastError: [docker, nvidia, token, help].find((result) => !result.ok)?.stderr || null, + lastError: [docker, nvidia, token].find((result) => !result.ok)?.stderr || null, }; await persistSurfaces(); - return { docker, nvidia, token, help, backendStatus }; + return backendStatus; } async function kimodoWorkerHealth(timeout = 10000) { @@ -742,9 +709,9 @@ async function runMotionJob(body, prompt, response) { } async function handleRequest(request, response) { - const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); + const url = new URL(request.url, `http://${request.headers.host || `${hosts[0]}:${args.port}`}`); if (url.pathname === "/" || url.pathname === "/health") { - await refreshBackendStatus({ includeKimodoHelp: false }); + await refreshBackendStatus(); json(response, 200, { ok: true, service: "vili", providerId, state: operatorState() }); } else if (url.pathname === "/provider-advertisement") { json(response, 200, providerAdvertisement()); @@ -761,12 +728,6 @@ async function handleRequest(request, response) { endpoint: `${ravenDeckUrl}/${providerId}`, }], }); - } else if (url.pathname === "/smoke") { - const result = await refreshBackendStatus({ includeKimodoHelp: true }); - const worker = await ensureKimodoWorker(); - await refreshBackendStatus({ includeKimodoHelp: false }); - const ok = result.backendStatus.kimodoHelp === "ok" && worker.ok && worker.body?.ok; - json(response, ok ? 200 : 502, { ok, ...result, worker: worker.body || worker }); } else if (url.pathname === "/gpu-permission" && request.method === "GET") { json(response, 200, gpuTenancy.status()); } else if (url.pathname === "/gpu-permission" && request.method === "POST") { @@ -779,8 +740,7 @@ async function handleRequest(request, response) { } async function healthCheck() { - const host = args.host === "0.0.0.0" ? "127.0.0.1" : args.host; - const response = await fetch(`http://${host}:${args.port}/health`); + const response = await fetch(`http://${hosts[0]}:${args.port}/health`); if (!response.ok) throw new Error(`health returned ${response.status}`); const body = await response.json(); if (!body.ok) throw new Error("health returned ok=false"); @@ -797,15 +757,13 @@ if (args.health) { process.exit(1); }); } else { + if (hosts.length === 0 || hosts.includes("0.0.0.0") || hosts.includes("::")) { + console.error("--host must name the addresses to listen on, never a wildcard."); + process.exit(2); + } await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true }); await persistSurfaces(); - const server = http.createServer((request, response) => { - handleRequest(request, response).catch((error) => { - json(response, 500, { ok: false, error: error.message, stack: process.env.NODE_ENV === "development" ? error.stack : undefined }); - }); - }); - const wss = new WebSocketServer({ noServer: true }); wss.on("connection", (socket) => { const sendSurface = () => { @@ -816,18 +774,27 @@ if (args.health) { socket.on("close", () => clearInterval(timer)); }); - server.on("upgrade", (request, socket, head) => { - const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); - if (url.pathname === `/eve/deck/${providerId}` || url.pathname === "/eve/deck") { - wss.handleUpgrade(request, socket, head, (ws) => wss.emit("connection", ws, request)); - } else { - socket.destroy(); - } - }); - - server.listen(args.port, args.host, () => { - console.log(`Vili listening on http://${args.host}:${args.port}`); + const servers = hosts.map((host) => { + const server = http.createServer((request, response) => { + handleRequest(request, response).catch((error) => { + json(response, 500, { ok: false, error: error.message, stack: process.env.NODE_ENV === "development" ? error.stack : undefined }); + }); + }); + server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url, `http://${request.headers.host || `${hosts[0]}:${args.port}`}`); + if (url.pathname === `/eve/deck/${providerId}` || url.pathname === "/eve/deck") { + wss.handleUpgrade(request, socket, head, (ws) => wss.emit("connection", ws, request)); + } else { + socket.destroy(); + } + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(args.port, host, resolve); + }); }); + await Promise.all(servers); + console.log(`Vili listening on ${hosts.map((host) => `http://${host}:${args.port}`).join(" ")}`); setInterval(() => persistSurfaces().catch((error) => console.error(error)), 5000); } From f71aeedad18258102cd6756c5ef895bb6f5bfa44 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 03:09:06 +0200 Subject: [PATCH 25/26] Pin that a client hang-up mid-job leaves the slot held Co-Authored-By: Claude Opus 5.5 --- scripts/test-gpu-gate-http.mjs | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/scripts/test-gpu-gate-http.mjs b/scripts/test-gpu-gate-http.mjs index 599d08e..7454b5e 100644 --- a/scripts/test-gpu-gate-http.mjs +++ b/scripts/test-gpu-gate-http.mjs @@ -374,3 +374,44 @@ test("http: with the permission off nothing a web page or a LAN caller sends rea } }); +test("http: a client that hangs up mid-job does not free the slot", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "vili-gate-hangup-")); + const stubDir = await mkdtemp(path.join(os.tmpdir(), "vili-gate-stub-")); + const d = await stubbedDaemon(stubDir, stateRoot); + const prompt = { prompt: "a person waves hello, never seen before" }; + const hangUp = new AbortController(); + try { + await setPermission(d, { allowed: true }); + const first = fetch(url(d, "/motion/generate"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(prompt), + signal: hangUp.signal, + }).catch((error) => error); + const deadline = Date.now() + 20000; + while (!existsSync(path.join(stubDir, "started"))) { + assert.ok(Date.now() < deadline, "the job never reached the worker"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + hangUp.abort(); + assert.equal((await first).name, "AbortError", "the client hung up"); + await new Promise((resolve) => setTimeout(resolve, 500)); + + assert.equal((await readPermission(d)).jobRunning, true, "the slot is still held after the hang-up"); + const second = await generate(d, prompt); + assert.equal(second.status, 503); + assert.equal((await second.json()).source, "slot"); + assert.equal(readFileSync(path.join(stubDir, "started"), "utf8"), "x", "no second job reached the worker"); + + await writeFile(path.join(stubDir, "release"), ""); + const freed = Date.now() + 20000; + while ((await readPermission(d)).jobRunning) { + assert.ok(Date.now() < freed, "the slot never freed when the job ended"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } finally { + await d.stop(); + await rm(stateRoot, { recursive: true, force: true }); + await rm(stubDir, { recursive: true, force: true }); + } +}); From 3adb61649771c8f5c29324cb6d4ebc0407adea52 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 03:09:22 +0200 Subject: [PATCH 26/26] Docs and launch scripts for the smoke deletion and the listen addresses package.json loses the smoke script; the README and the service contract say no GET changes anything and name the listen addresses and the missing CORS; the launch and install scripts default -HostName to 127.0.0.1,10.77.0.4 instead of 0.0.0.0. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++++++++----- docs/verse-service-contract.md | 6 +++--- package.json | 1 - scripts/install-vili-task.ps1 | 2 +- scripts/restart-vili-daemon.ps1 | 2 +- scripts/run-vili-daemon.ps1 | 2 +- scripts/start-vili-daemon.ps1 | 2 +- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e07fa1e..6dfe397 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ first backend is Kimodo on Raven, running through Docker Engine inside - **Value:** a warm, supervised path from a text prompt to generated body motion. Vili runs Kimodo as a resident GPU worker on Raven and returns motion artifacts (`motion.npz`, optional BVH) for a job. - **Problem:** Personas and games need generated motion, but cold per-request container runs took about 195 seconds per 1 second clip, versus about 54 warm. One daemon owns job identity, worker lifecycle and artifact references so callers do not touch Docker, WSL or model state. -- **Entry points:** `scripts/vili-daemon.mjs` (the daemon; `npm start`, `npm run health`, `npm run smoke`, `npm run provider`), `scripts/kimodo-resident-worker.py` (the resident worker), `POST /motion/generate` (see Motion Generation), `docs/verse-service-contract.md` and `docs/motion-pipeline-target.md`. -- **Live:** the resident Kimodo worker on Raven, `POST /motion/generate`, `GET /smoke`, and the CultCache service store. It runs under a Raven scheduled task, not under Idunn. It has `motion.npz` and optional BVH output on the SOMA skeleton; there is no FBX export. +- **Entry points:** `scripts/vili-daemon.mjs` (the daemon; `npm start`, `npm run health`, `npm run provider`), `scripts/kimodo-resident-worker.py` (the resident worker), `POST /motion/generate` (see Motion Generation), `docs/verse-service-contract.md` and `docs/motion-pipeline-target.md`. +- **Live:** the resident Kimodo worker on Raven, `POST /motion/generate`, and the CultCache service store. It runs under a Raven scheduled task, not under Idunn. It has `motion.npz` and optional BVH output on the SOMA skeleton; there is no FBX export. - **Planned (target, 2026-10-20):** FBX animation export on the SOMA skeleton and a text-prompt-to-Unity-clip path with no hand steps, with each artifact recording prompt, constraints, seed and model. Retargeting stays with the consuming engine; see `docs/motion-pipeline-target.md`. ## Authority @@ -41,6 +41,11 @@ Default compatibility endpoint: http://10.77.0.4:8824 ``` +The daemon listens only on the addresses `--host` names, comma-separated, one server each: on +Raven `127.0.0.1` (the tray and `--health`) and `10.77.0.4` (the WireGuard peers: Starfire and +the services that call `POST /motion/generate`). A wildcard such as `0.0.0.0` refuses to start. +No response carries a CORS header, so a web page cannot read the daemon either; no browser +caller exists. Vili is not an Idunn target today. Its lifecycle is the `GameCult\Vili` scheduled task on Raven, and `GET /health` is an operator lowering. Admission to Idunn is motion-pipeline:question:vili-under-idunn. @@ -63,7 +68,6 @@ Important routes: - `GET /eve/operator` - `GET /eve/deck/providers` - `WS /eve/deck/vili.animation` -- `GET /smoke` - `POST /motion/generate` ## Motion Generation @@ -145,8 +149,8 @@ Vili keeps Kimodo warm through a resident worker container named the loaded model. Because Raven's Docker Engine runs inside WSL, Vili also keeps a tiny WSL sleep-loop alive while the worker is resident so the Docker substrate does not disappear underneath the model. `GET /health` observes -worker state without launching model load. `GET /smoke` verifies the backend -and starts the resident worker when it is absent. +worker state without launching model load and without changing anything. Only a +`POST /motion/generate` that holds the GPU slot starts the worker. Kimodo's 8B text encoder (LLM2Vec, Llama 3) never lives in the worker. The worker answers Kimodo's encoder call from a CultCache store of float32 vectors, diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 9f6fd09..abb4ca7 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -158,7 +158,7 @@ It mounts: Inside the worker, Kimodo owns synthesis. Vili owns worker lifecycle, request/response persistence, timeout handling, and artifact references. Direct -user requests, programmatic requests, and smoke-started warmup all use the same +user requests and programmatic requests use the same resident worker health and generation path. Observed on `2026-06-12`: first worker model load took about 100 seconds. Once @@ -169,5 +169,5 @@ resident, five 1 second / 20 step service-path generations averaged about Health checks must be cheap. They may verify process, port, backend metadata, and cached readiness, but they must not launch a Kimodo generation job or start -model load. Smoke checks may start the resident worker because their purpose is -backend readiness, not cheap liveness. +model load. They change nothing: no GET route starts the worker, Docker or the GPU. Only a +`POST /motion/generate` that holds the GPU slot starts the worker. diff --git a/package.json b/package.json index 4164e53..5f67561 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "health": "node scripts/vili-daemon.mjs --health", "test": "node --test scripts/test-gpu-tenancy.mjs", "test:http": "node --test scripts/test-gpu-gate-http.mjs", - "smoke": "node scripts/smoke-vili.mjs", "provider": "node scripts/vili-daemon.mjs --print-provider-advertisement" }, "dependencies": { diff --git a/scripts/install-vili-task.ps1 b/scripts/install-vili-task.ps1 index f00b14a..10cd66a 100644 --- a/scripts/install-vili-task.ps1 +++ b/scripts/install-vili-task.ps1 @@ -1,7 +1,7 @@ param( [string]$TaskName = "GameCult\Vili", [int]$Port = 8824, - [string]$HostName = "0.0.0.0", + [string]$HostName = "127.0.0.1,10.77.0.4", [string]$StateRoot = "E:\Projects\Vili\.vili" ) diff --git a/scripts/restart-vili-daemon.ps1 b/scripts/restart-vili-daemon.ps1 index e935d12..44706ff 100644 --- a/scripts/restart-vili-daemon.ps1 +++ b/scripts/restart-vili-daemon.ps1 @@ -1,6 +1,6 @@ param( [int]$Port = 8824, - [string]$HostName = "0.0.0.0", + [string]$HostName = "127.0.0.1,10.77.0.4", [string]$StateRoot = "E:\Projects\Vili\.vili" ) diff --git a/scripts/run-vili-daemon.ps1 b/scripts/run-vili-daemon.ps1 index 0e10cba..dcf1333 100644 --- a/scripts/run-vili-daemon.ps1 +++ b/scripts/run-vili-daemon.ps1 @@ -1,6 +1,6 @@ param( [int]$Port = 8824, - [string]$HostName = "0.0.0.0", + [string]$HostName = "127.0.0.1,10.77.0.4", [string]$StateRoot = "E:\Projects\Vili\.vili" ) diff --git a/scripts/start-vili-daemon.ps1 b/scripts/start-vili-daemon.ps1 index 3d49de7..d167917 100644 --- a/scripts/start-vili-daemon.ps1 +++ b/scripts/start-vili-daemon.ps1 @@ -1,6 +1,6 @@ param( [int]$Port = 8824, - [string]$HostName = "0.0.0.0", + [string]$HostName = "127.0.0.1,10.77.0.4", [string]$StateRoot = "E:\Projects\Vili\.vili" )