From 93c545b03d21e87abc2dc383baac6e85196b8f73 Mon Sep 17 00:00:00 2001 From: irisfeng <6022447+irisfeng@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:19:05 +0800 Subject: [PATCH] local sandbox: recreate a parked container whose docker network is gone Docker containers record the network they were created with by ID. When that network is removed out-of-band (daemon restart with a changed address pool, a manual network prune), a parked sandbox container can never start again: docker start fails with 'network not found' and the raw error surfaces to the user on their next session. Before reusing a stopped container, check that the networks it still references exist; when one is gone, fall through to the existing recreate path, which removes the container and runs a fresh one on a newly created network. The per-scope home volume is remounted, so no user data is lost. Give the scratch path the same guard plus the rm its fall-through previously never needed (reaching it used to imply the container was gone). --- src/sandbox/local-sandbox.ts | 20 ++++++++++++++++++-- test/local-sandbox.test.ts | 35 +++++++++++++++++++++++++++++++++++ test/support/fake-docker.ts | 6 ++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/sandbox/local-sandbox.ts b/src/sandbox/local-sandbox.ts index 988e25e36..ea7b4c2ac 100644 --- a/src/sandbox/local-sandbox.ts +++ b/src/sandbox/local-sandbox.ts @@ -146,6 +146,21 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox return { running: running === "true", imageId }; } + async function networksIntact(name: string): Promise { + const r = await dexec(["inspect", "-f", "{{json .NetworkSettings.Networks}}", name]); + if (r.code !== 0) return false; + let endpoints: Record; + try { + endpoints = JSON.parse(r.stdout.trim() || "{}"); + } catch { + return false; + } + for (const endpoint of Object.values(endpoints ?? {})) { + if (endpoint.NetworkID && (await dexec(["network", "inspect", endpoint.NetworkID])).code !== 0) return false; + } + return true; + } + async function resolvePort(name: string): Promise { const cached = portByName.get(name); if (cached) return cached; @@ -293,7 +308,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox const name = localContainerName(scope); scopeByContainer.set(name, scope); const state = await containerState(name); - if (state && state.imageId === imageId) { + if (state && state.imageId === imageId && (state.running || (await networksIntact(name)))) { if (!state.running) await startContainer(name); else await connectCore(await ensureNetwork(name)); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); @@ -318,12 +333,13 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox const name = localScratchName(key); scratchByKey.set(key, name); const state = await containerState(name); - if (state) { + if (state && (state.running || (await networksIntact(name)))) { if (!state.running) await startContainer(name); else await connectCore(await ensureNetwork(name)); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: false }; } + if (state) await dexec(["rm", "-f", name]); await runContainer(name, undefined, false); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: true }; diff --git a/test/local-sandbox.test.ts b/test/local-sandbox.test.ts index 47dc81c33..2231498e5 100644 --- a/test/local-sandbox.test.ts +++ b/test/local-sandbox.test.ts @@ -314,3 +314,38 @@ test("containerized core joins each sandbox network and reaches the daemon by co await sb.teardown(h, { destroy: true }); assert.equal(fake.connections.has(`${localNetworkName(h.id)}|qm-test-core`), false); }); + +test("a parked container whose network was pruned is recreated with its home volume", async () => { + const fake = installFakeDocker(daemonPort); + const sb = makeSandbox(fake); + const layers = rw(scopeId("personal", "U41")); + const h1 = await sb.provision(layers); + await sb.teardown(h1); + assert.equal(fake.containers.get(h1.id)!.running, false); + const volume = fake.containers.get(h1.id)!.volume!; + fake.networks.delete(localNetworkName(h1.id)); + + const h2 = await makeSandbox(fake).provision(layers); + assert.equal(h2.id, h1.id); + assert.equal(fake.runCount, 2, "container recreated after its network vanished"); + assert.equal(fake.containers.get(h2.id)!.running, true); + assert.equal(fake.volumes.has(volume), true, "volume survived the recreate"); + assert.equal(fake.containers.get(h2.id)!.volume, volume, "recreate remounted the same volume"); + assert.equal(fake.networks.has(localNetworkName(h1.id)), true, "network recreated"); + assert.equal(h2.coldStart, false, "existing volume means a warm home"); +}); + +test("a parked scratch box whose network was pruned is recreated", async () => { + const fake = installFakeDocker(daemonPort); + const sb = makeSandbox(fake); + const layers = rw(scopeId("personal", "U42")); + const h1 = await sb.provision(layers, { scratch: { key: "pruned" } }); + fake.containers.get(h1.id)!.running = false; + fake.networks.delete(localNetworkName(h1.id)); + + const h2 = await sb.provision(layers, { scratch: { key: "pruned" } }); + assert.equal(h2.id, h1.id); + assert.equal(fake.runCount, 2, "scratch container recreated"); + assert.equal(fake.containers.get(h2.id)!.running, true); + assert.equal(fake.networks.has(localNetworkName(h1.id)), true, "network recreated"); +}); diff --git a/test/support/fake-docker.ts b/test/support/fake-docker.ts index d456a5be6..4df726f8a 100644 --- a/test/support/fake-docker.ts +++ b/test/support/fake-docker.ts @@ -6,6 +6,7 @@ export interface FakeContainer { running: boolean; labels: Record; volume?: string; + network?: string; args: string[]; } @@ -54,6 +55,7 @@ export function installFakeDocker(daemonPort: number): FakeDocker { const [k = "", v = ""] = args[++i]!.split("="); c.labels[k] = v; } else if (a === "-v") c.volume = args[++i]!.split(":")[0]!; + else if (a === "--network") c.network = args[++i]!; else if (a === "-p" || a === "--cpus" || a === "--memory") i++; } return c; @@ -75,6 +77,8 @@ export function installFakeDocker(daemonPort: number): FakeDocker { const name = rest[rest.length - 1]!; const c = containers.get(name); if (!c) return fail(`Error: No such object: ${name}`); + if (rest.includes("{{json .NetworkSettings.Networks}}")) + return ok(JSON.stringify(c.network ? { [c.network]: { NetworkID: c.network } } : {})); return ok(`${c.running} ${c.imageId}`); } case "network": { @@ -122,6 +126,8 @@ export function installFakeDocker(daemonPort: number): FakeDocker { case "start": { const c = containers.get(rest[0]!); if (!c) return fail("Error: No such container"); + if (c.network && !networks.has(c.network)) + return fail(`Error response from daemon: failed to set up container networking: network ${c.network} not found`); c.running = true; return ok(rest[0]!); }