diff --git a/docs/guide/deploying.md b/docs/guide/deploying.md index 81536e1..b2e3f7b 100644 --- a/docs/guide/deploying.md +++ b/docs/guide/deploying.md @@ -41,6 +41,17 @@ To install Browser or Voice from Settings: Follow [Docker add-ons](/guide/add-ons) for installation, controls, storage and cleanup. +## Resuming container sessions + +The container executor automatically removes completed runners. Before starting +a session, it also removes a leftover stopped runner with the same name, but +only when its ownership labels match that session. + +A running runner is left alone. Wait for its current task to finish before +resuming. If an unrelated container uses the same name, the portal reports the +collision; inspect that container and rename or remove it yourself once you +have identified it. The portal does not force-delete it. + ## Updating ```bash diff --git a/server/src/executors/index.ts b/server/src/executors/index.ts index 5773959..a368012 100644 --- a/server/src/executors/index.ts +++ b/server/src/executors/index.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import path from "node:path"; +import { removeStoppedRunner } from "./stale-container.js"; import { PiRpcClient } from "../pi/rpc-client.js"; import { SdkPiClient } from "../pi/sdk-client.js"; import type { PiClient } from "../pi/types.js"; @@ -147,6 +148,7 @@ export class ContainerExecutor implements Executor { ...piArgs({ ...opts }, "/sessions"), ]; + await removeStoppedRunner(opts.sessionId); const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] }); return new PiRpcClient(child); } diff --git a/server/src/executors/stale-container.ts b/server/src/executors/stale-container.ts new file mode 100644 index 0000000..ae41646 --- /dev/null +++ b/server/src/executors/stale-container.ts @@ -0,0 +1,30 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +type DockerCommand = (args: string[]) => Promise; +const docker: DockerCommand = async (args) => (await exec('docker', args)).stdout; + +/** Reclaim an old session name without killing a live or unrelated container. */ +export async function removeStoppedRunner(sessionId: string, run: DockerCommand = docker): Promise { + const name = `pithagoras-${sessionId}`; + let output: string; + try { + output = await run(['container', 'inspect', name]); + } catch (error) { + const stderr = (error as {stderr?: string}).stderr ?? ''; + if (/No such (?:object|container):/i.test(stderr)) return; + throw error; + } + const [container] = JSON.parse(output); + const labels = container?.Config?.Labels; + if (labels?.['pithagoras.managed'] !== 'true' || labels?.['pithagoras.session'] !== sessionId) { + throw new Error(`Container ${name} is not owned by this session; remove or rename it manually.`); + } + if (!['created', 'exited', 'dead'].includes(container.State?.Status)) { + throw new Error(`Container ${name} is still active; wait for it to stop before resuming.`); + } + // Use the inspected ID, never force removal: a container that starts between + // inspect and rm remains protected by Docker's running-container check. + await run(['container', 'rm', container.Id]); +} diff --git a/tests/stale-container.test.mts b/tests/stale-container.test.mts new file mode 100644 index 0000000..7f34340 --- /dev/null +++ b/tests/stale-container.test.mts @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {removeStoppedRunner} from '../server/src/executors/stale-container.ts'; +const fixture=(status='exited',managed='true')=>JSON.stringify([{Id:'immutable-container-id',Config:{Labels:{'pithagoras.managed':managed,'pithagoras.session':'test'}},State:{Status:status}}]); +test('only stopped managed runners are removed, by ID without force',async()=>{ + for(const status of ['created','exited','dead']){ + const calls:string[][]=[]; + await removeStoppedRunner('test',async args=>{calls.push(args);return calls.length===1?fixture(status):''}); + assert.deepEqual(calls,[['container','inspect','pithagoras-test'],['container','rm','immutable-container-id']]); + } +}); +test('running or unrelated containers are never removed',async()=>{ + for(const output of [fixture('running'),fixture('paused'),fixture('restarting'),fixture('exited','false')]){ + let calls=0; + await assert.rejects(removeStoppedRunner('test',async()=>{calls++;return output})); + assert.equal(calls,1); + } +}); +test('missing container is normal; daemon and removal failures propagate',async()=>{ + await removeStoppedRunner('test',async()=>{throw Object.assign(new Error(),{stderr:'Error: No such container: pithagoras-test'})}); + await assert.rejects(removeStoppedRunner('test',async()=>{throw Object.assign(new Error('daemon unavailable'),{stderr:'Cannot connect to Docker daemon'})}),/daemon unavailable/); + let calls=0; + await assert.rejects(removeStoppedRunner('test',async()=>{if(++calls===1)return fixture();throw new Error('container is running')}),/container is running/); +});