Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/guide/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions server/src/executors/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
30 changes: 30 additions & 0 deletions server/src/executors/stale-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const exec = promisify(execFile);
type DockerCommand = (args: string[]) => Promise<string>;
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<void> {
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]);
}
24 changes: 24 additions & 0 deletions tests/stale-container.test.mts
Original file line number Diff line number Diff line change
@@ -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/);
});