The lab said nothing was running while two machines were

'incus list' failed because this shell had no permission to reach the daemon,
incusOk returned null, and the caller wrote ?? "[]". So 'mesh-lab list'
printed 'no scenario instances standing' -- confidently, about a question it
had never managed to ask.

The comment on incusOk warns about exactly this, in those words: absence and
success made indistinguishable. Three of its own callers then did it. Two
listings and the live diagram, which would have drawn an empty scenario rather
than fail -- a picture that is confidently wrong, which is worse than none.

Anything enumerating what exists now goes through enumerate() and throws.
incusOk stays right where failure genuinely means no, like instanceExists,
and there is a test holding that line so this does not get over-corrected
until nothing can be asked at all.

Worth noting 'mesh-lab check' already diagnoses this precise cause, down to
'a session that predates it cannot see it'. The diagnosis existed; the
listing just never asked for it.
This commit is contained in:
2026-08-29 11:54:33 +02:00
parent f88dbcc51e
commit 88cf89194a
4 changed files with 85 additions and 11 deletions
+10 -3
View File
@@ -1,8 +1,10 @@
# One machine, raising a substrate from the bundle its host carries. # One machine, raising a substrate from the bundle its host carries.
# #
# This is the bootstrap class (novox/hq ADR 0009): no forge, no control plane, no delivery. It # This is the bootstrap class (novox/hq ADR 0009): no forge, no control plane to talk to, no
# exists to develop the first three steps of raising a mesh — a container runtime, a store, and # delivery. It exists to develop the steps of raising a mesh on a machine with no route out —
# a database inside it — which is as far as the bootstrap can go until a control plane exists. # a container runtime, a store, the control plane's schema in it, and the broker.
#
# It stops before the control plane *runs*, because there is nothing for it to serve yet.
scenario: first-node scenario: first-node
segments: segments:
@@ -15,8 +17,13 @@ machines:
at: { segment: hosting, address: [192.0.2.10] } at: { segment: hosting, address: [192.0.2.10] }
inbound: allow inbound: allow
# Placed into a registry the scenario raises, which is what a real node pulls from anyway. The
# digests below are the ones that registry assigns, and that satisfies pinning: what is required
# is a reference that is exact and cannot move (novox/hq ADR 0006).
images: images:
- postgres:17-alpine - postgres:17-alpine
- cloudamqp/lavinmq:latest
- mesh-control:development
place: place:
all: [host, runtime] all: [host, runtime]
+5 -2
View File
@@ -8,7 +8,7 @@
* by the running machine now — nothing is inferred from a file on disk. * by the running machine now — nothing is inferred from a file on disk.
*/ */
import { incusOk, taggedNetworks } from "../incus/client.ts"; import { incus, incusOk, taggedNetworks } from "../incus/client.ts";
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts"; import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
interface RawInstance { interface RawInstance {
@@ -28,7 +28,10 @@ interface RawInstance {
export async function diagramFromLive(instanceId: string): Promise<Diagram> { export async function diagramFromLive(instanceId: string): Promise<Diagram> {
const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId); const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId);
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]"; // Not `incusOk(...) ?? "[]"`. A picture is read from what runs (novox/hq ADR 0018), and a read
// that failed and became an empty list would draw an empty scenario rather than fail — a
// diagram that is confidently wrong, which is worse than no diagram.
const json = (await incus(["list", "--format", "json"], 30_000)).stdout.trim() || "[]";
const parsed = JSON.parse(json) as RawInstance[]; const parsed = JSON.parse(json) as RawInstance[];
const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId); const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
if (mine.length === 0 && networks.length === 0) throw new Error(`no scenario instance '${instanceId}'`); if (mine.length === 0 && networks.length === 0) throw new Error(`no scenario instance '${instanceId}'`);
+29 -6
View File
@@ -111,6 +111,22 @@ export async function incusOk(args: string[], timeoutMs = 60_000): Promise<strin
} }
} }
/**
* Ask incus what exists, where answering "none" without having looked would be a lie.
*
* The comment on `incusOk` above warns that absence and success must not be made
* indistinguishable. Three of its callers then wrote `?? "[]"` and did exactly that, and it cost
* a session: `mesh-lab list` reported *no scenario instances standing* while two were standing,
* because this shell had no permission to reach the daemon. The lab was not wrong about the
* instances — it had never managed to ask.
*
* So anything enumerating what exists comes through here and throws. `incusOk` remains right for
* questions where failure genuinely means no, like `instanceExists`.
*/
async function enumerate(args: string[], timeoutMs = 30_000): Promise<string> {
return (await incus(args, timeoutMs)).stdout;
}
/** Did the command work? For commands whose output is not the point. */ /** Did the command work? For commands whose output is not the point. */
export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> { export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> {
return (await incusOk(args, timeoutMs)) !== null; return (await incusOk(args, timeoutMs)) !== null;
@@ -168,14 +184,18 @@ export interface TaggedInstance {
* nothing. Metadata is what the instance actually knows about itself. * nothing. Metadata is what the instance actually knows about itself.
*/ */
export async function taggedInstances(): Promise<TaggedInstance[]> { export async function taggedInstances(): Promise<TaggedInstance[]> {
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]"; const json = (await enumerate(["list", "--format", "json"])).trim() || "[]";
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(json); parsed = JSON.parse(json);
} catch { } catch {
return []; throw new IncusError(["list", "--format", "json"],
`incus answered something that is not JSON: ${json.slice(0, 200)}`, null);
}
if (!Array.isArray(parsed)) {
throw new IncusError(["list", "--format", "json"],
"incus answered JSON that is not a list of instances", null);
} }
if (!Array.isArray(parsed)) return [];
const tagged: TaggedInstance[] = []; const tagged: TaggedInstance[] = [];
for (const entry of parsed) { for (const entry of parsed) {
@@ -199,14 +219,17 @@ export interface TaggedNetwork {
} }
export async function taggedNetworks(): Promise<TaggedNetwork[]> { export async function taggedNetworks(): Promise<TaggedNetwork[]> {
const json = (await incusOk(["network", "list", "--format", "json"], 30_000)) ?? "[]"; const args = ["network", "list", "--format", "json"];
const json = (await enumerate(args)).trim() || "[]";
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(json); parsed = JSON.parse(json);
} catch { } catch {
return []; throw new IncusError(args, `incus answered something that is not JSON: ${json.slice(0, 200)}`, null);
}
if (!Array.isArray(parsed)) {
throw new IncusError(args, "incus answered JSON that is not a list of networks", null);
} }
if (!Array.isArray(parsed)) return [];
const tagged: TaggedNetwork[] = []; const tagged: TaggedNetwork[] = [];
for (const entry of parsed) { for (const entry of parsed) {
+41
View File
@@ -0,0 +1,41 @@
// Set before importing: the client reads MESH_LAB_INCUS once, at module load.
// `false` is a real program that exits non-zero and prints nothing — which is also the worst
// case, because an empty stderr is how a failure arrives with no explanation.
process.env["MESH_LAB_INCUS"] = "false";
import { test } from "node:test";
import assert from "node:assert/strict";
import { instanceExists, taggedInstances, taggedNetworks } from "../src/incus/client.ts";
/**
* "I cannot see" must never be answered as "there is nothing there."
*
* This is the fault the comment on `incusOk` warns about, committed by three of its own callers
* writing `?? "[]"`. It cost a session: `mesh-lab list` printed *no scenario instances standing*
* while two were standing, because the shell had no permission to reach the daemon. Nothing was
* wrong with the lab's knowledge of the instances — it had never managed to ask.
*
* The same shape as the fault the node host exists to prevent, in the tool that tests the host:
* a service that does not exist reported as `stopped`.
*/
test("listing instances fails rather than reporting none", async () => {
await assert.rejects(
() => taggedInstances(),
"a failed `incus list` came back as an empty list; every caller would report nothing running",
);
});
test("listing networks fails rather than reporting none", async () => {
await assert.rejects(
() => taggedNetworks(),
"a failed `incus network list` came back as an empty list",
);
});
test("but a question whose failure genuinely means no still answers no", async () => {
// The distinction worth keeping. `instanceExists` asks about one named thing, and a daemon
// that will not answer is not evidence the instance exists — so false is honest here, and
// making this throw too would be over-correcting until nothing can be asked at all.
assert.equal(await instanceExists("anything"), false);
});