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 <noreply@anthropic.com>
196 lines
8.4 KiB
JavaScript
196 lines
8.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { admitThen, createGpuTenancy, gpuTenancyOptionsFromEnv, parseFreeMiB, placementRefusal } from "./gpu-tenancy.mjs";
|
|
|
|
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(r.gate, async () => { calls += 1; });
|
|
assert.deepEqual(refused, { ok: false, code: "gpu-busy", source: "household", gpu: { allowed: false, changedAt: null } });
|
|
assert.equal(calls, 0);
|
|
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("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", () => {
|
|
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" }, "vili-1"), null);
|
|
assert.equal(placementRefusal({ ok: false, code: null }, "vili-1"), null);
|
|
assert.equal(placementRefusal(null, "vili-1"), null);
|
|
});
|
|
|
|
test("env override: a valid value is read, absent is left to the default", () => {
|
|
assert.deepEqual(gpuTenancyOptionsFromEnv({}), {});
|
|
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 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),
|
|
);
|
|
}
|
|
});
|