Skip to content

Commit a56bbdc

Browse files
mchwangclaude
andauthored
Keep each Docker test run's containers and counts to itself (#185)
Each run of the Docker-backed supervisor and container tests now has its own runner owner, and the supervisor tests a per-run attempt-ID suffix, so a failed run's leftover container cannot take the next run's name. Inventory counts cover only this run's objects (by runner owner or allocation), so concurrent runs no longer count, or force-remove, each other's objects. A shared fixture, test/fixtures/docker-sweep.ts, removes what each run still owns after the ordinary afterAll cleanup and reports what it finds. The first supervisor test gets a 60 s timeout. Ported from the local branch fix/supervisor-test-cleanup (84dd662). Its macOS /var vs /private/var fix was already on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aded16e commit a56bbdc

3 files changed

Lines changed: 116 additions & 72 deletions

File tree

‎test/agent-container.test.ts‎

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFileSync, spawnSync } from 'node:child_process';
2+
import { removeRunnerObjects } from './fixtures/docker-sweep.ts';
23
import { fixtureGit } from './fixtures/git.ts';
34
import { createHash, randomBytes, randomUUID } from 'node:crypto';
45
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync,
@@ -39,7 +40,8 @@ vi.mock('node:fs', async importOriginal => {
3940
return actual.realpathSync(path);
4041
}) as typeof actual.realpathSync };
4142
});
42-
const TEST_RUNNER_OWNER = '0123456789abcdef0123456789abcdef';
43+
// Each run has its own runner owner, so counting the storage it owns ignores what a concurrent or failed run holds.
44+
const TEST_RUNNER_OWNER = randomBytes(16).toString('hex');
4345
const testOwner = (attemptId = 'fixture') => ({ runnerOwner: TEST_RUNNER_OWNER, attemptId, allocationId: randomUUID() });
4446

4547
const roots: string[] = [];
@@ -52,9 +54,16 @@ const git = fixtureGit;
5254
const docker = (...args: string[]) => execFileSync('docker', args, {
5355
encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
5456
}).trim();
57+
// Every volume, network and container of one allocation, found by that label alone: another run's objects never
58+
// count, and neither a missing nor a wrong runner label can hide one.
59+
const byAllocation = (allocationId: string) => ['volume', 'network'].flatMap(kind => docker(kind, 'ls', '--quiet',
60+
'--filter', `label=io.codeboost.allocation=${allocationId}`).split('\n').filter(Boolean))
61+
.concat(docker('ps', '--all', '--quiet', '--filter', `label=io.codeboost.allocation=${allocationId}`).split('\n')
62+
.filter(Boolean));
5563

5664
function fixture(options: { limits?: Parameters<typeof prepareTaskFilesystems>[1]; historyBytes?: number;
57-
hostile?: (source: string, root: string) => void; beforeSeed?: (clone: string) => void; objectFormat?: 'sha256' } = {}) {
65+
hostile?: (source: string, root: string) => void; beforeSeed?: (clone: string) => void; objectFormat?: 'sha256';
66+
owner?: ReturnType<typeof testOwner> } = {}) {
5867
const root = mkdtempSync(join(tmpdir(), 'agent-container-')); roots.push(root);
5968
const source = join(root, 'source'), staging = join(root, 'staging'), input = join(root, 'input');
6069
mkdirSync(source); mkdirSync(staging); mkdirSync(input);
@@ -75,7 +84,7 @@ function fixture(options: { limits?: Parameters<typeof prepareTaskFilesystems>[1
7584
options.beforeSeed?.(clone.directory);
7685
const filesystems = prepareTaskFilesystems(clone, options.limits ?? {
7786
workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512,
78-
}, imageId, testOwner());
87+
}, imageId, options.owner ?? testOwner());
7988
taskFilesystems.push(filesystems);
8089
const fakeAuth = join(root, 'auth.json'); writeFileSync(fakeAuth, '{}', { mode: 0o600 });
8190
return { root, source, input, clone, filesystems, fakeAuth };
@@ -124,13 +133,20 @@ afterEach(async () => {
124133
for (const network of vendorNetworks.splice(0).reverse()) await removeVendorNetwork(network);
125134
}, 120_000);
126135
afterAll(async () => {
127-
for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' });
128-
for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems);
129-
for (const profile of profiles) await disposeContainerProfile(profile);
130-
for (const network of vendorNetworks.splice(0).reverse()) await removeVendorNetwork(network);
131-
for (const root of roots.reverse()) {
132-
chmodSync(join(root, 'input'), 0o700);
133-
rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
136+
try {
137+
for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' });
138+
for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems);
139+
for (const profile of profiles) await disposeContainerProfile(profile);
140+
for (const network of vendorNetworks.splice(0).reverse()) await removeVendorNetwork(network);
141+
for (const root of roots.reverse()) {
142+
chmodSync(join(root, 'input'), 0o700);
143+
rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
144+
}
145+
} finally {
146+
// The runner owner is this run's own, so nothing else would find what this run leaves behind. Objects a test
147+
// makes under another owner or without the runner label are not found here; those tests remove their own. It may
148+
// not run before a hook timeout.
149+
removeRunnerObjects(TEST_RUNNER_OWNER);
134150
}
135151
}, 120_000);
136152

@@ -282,14 +298,12 @@ describe('real Docker agent isolation', () => {
282298
symlinkSync(target, join(source, 'L'));
283299
}] as const),
284300
] as const)('refuses to seed a repository with %s, and leaves no storage behind', async (_label, hostile) => {
285-
const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'),
286-
docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean);
287-
const before = new Set(owned());
288-
const refused = (() => { try { fixture({ hostile }); } catch (error) { return error; } })();
301+
const owner = testOwner();
302+
const refused = (() => { try { fixture({ hostile, owner }); } catch (error) { return error; } })();
289303
// Its own error type, so a caller can tell an unusable repository from a Docker failure, with the reason first.
290304
expect(refused).toBeInstanceOf(UnusableRepositoryError);
291305
expect((refused as Error).message).toMatch(/^Repository link leaves the checkout: /);
292-
expect(owned().filter(id => !before.has(id))).toEqual([]);
306+
expect(byAllocation(owner.allocationId)).toEqual([]);
293307
}, 60_000);
294308

295309
// Names are bytes: a link whose name is not UTF-8, beside a file named what a decoder turns it into. Linux only: a
@@ -307,16 +321,14 @@ describe('real Docker agent isolation', () => {
307321
head: git(data.source, 'rev-parse', 'HEAD') });
308322
// /work/.git is mounted read-only, which stops writes but not reads through a link.
309323
symlinkSync('/run/codeboost-auth/codex/auth.json', join(clone.directory, '.git', 'credential'));
310-
const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'),
311-
docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean);
312-
const before = new Set(owned());
324+
const owner = testOwner();
313325
const refused = (() => {
314326
try { prepareTaskFilesystems(clone, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024,
315-
metadataInodes: 512 }, imageId, testOwner()); } catch (error) { return error; }
327+
metadataInodes: 512 }, imageId, owner); } catch (error) { return error; }
316328
})();
317329
expect(refused).toBeInstanceOf(UnusableRepositoryError);
318330
expect((refused as Error).message).toMatch(/^Repository Git metadata contains a link: /);
319-
expect(owned().filter(id => !before.has(id))).toEqual([]);
331+
expect(byAllocation(owner.allocationId)).toEqual([]);
320332
}, 60_000);
321333

322334
it('seeds links that stay inside the checkout, including loops and not-yet-existing targets', async () => {
@@ -336,13 +348,11 @@ describe('real Docker agent isolation', () => {
336348
}, 60_000);
337349

338350
it('fails closed without leaving storage when a repository exceeds its allocation', async () => {
339-
const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'),
340-
docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean);
341-
const before = new Set(owned());
342-
expect(() => fixture({ historyBytes: 4 * 1024 * 1024, limits: {
351+
const owner = testOwner();
352+
expect(() => fixture({ owner, historyBytes: 4 * 1024 * 1024, limits: {
343353
workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 1024 * 1024, metadataInodes: 512,
344354
} })).toThrow();
345-
expect(owned().filter(id => !before.has(id))).toEqual([]);
355+
expect(byAllocation(owner.allocationId)).toEqual([]);
346356
}, 60_000);
347357

348358
it('persists execution changes while replacing HOME and scratch for each invocation', async () => {
@@ -634,13 +644,13 @@ describe('real Docker agent isolation', () => {
634644
};
635645

636646
it('rejects a vendor network or proxy without its runner label, and removes them', async () => {
637-
const data = fixture(), egress = () => docker('network', 'ls', '--quiet', '--filter', 'label=io.codeboost.egress');
638-
const before = egress();
647+
// Counted by allocation, not by runner: first the network, then the proxy, is made without the runner label.
648+
const data = fixture(), allocations = [randomUUID(), randomUUID()];
639649
await withoutRunnerLabel([['network', 'create']], () => expect(createVendorNetwork(invocation(data.clone, 'planning'),
640-
imageId, randomUUID())).rejects.toThrow('changed after allocation'));
650+
imageId, allocations[0]!)).rejects.toThrow('changed after allocation'));
641651
await withoutRunnerLabel([['create', '--name']], () => expect(createVendorNetwork(invocation(data.clone, 'planning'),
642-
imageId, randomUUID())).rejects.toThrow('changed after allocation'));
643-
expect(egress()).toBe(before);
652+
imageId, allocations[1]!)).rejects.toThrow('changed after allocation'));
653+
expect(allocations.flatMap(byAllocation)).toEqual([]);
644654
}, 120_000);
645655

646656
// Storage has no captured IDs, so cleanup refuses an object without every owner label; the test removes its own.
@@ -707,10 +717,6 @@ describe('real Docker agent isolation', () => {
707717
"const allocation = args.find(arg => arg.startsWith('io.codeboost.allocation='));",
708718
`run(['volume', 'create', '--label', 'io.codeboost.runner=${'f'.repeat(32)}', '--label', allocation, '${name}']);`,
709719
].join('\n');
710-
const byAllocation = (allocationId: string) => ['volume', 'network'].flatMap(kind => docker(kind, 'ls', '--quiet',
711-
'--filter', `label=io.codeboost.allocation=${allocationId}`).split('\n').filter(Boolean))
712-
.concat(docker('ps', '--all', '--quiet', '--filter', `label=io.codeboost.allocation=${allocationId}`).split('\n')
713-
.filter(Boolean));
714720

715721
it('backs out of task storage when a concurrent process claims the same allocation ID', async () => {
716722
const data = fixture(), owner = testOwner('storage-race'), rival = `codeboost-race-${randomUUID()}`;
@@ -2382,8 +2388,9 @@ describe('real Docker agent isolation', () => {
23822388
const data = fixture();
23832389
const clone = createTaskClone({ source: data.source, parent: join(data.root, 'staging'), taskId: 'task-late',
23842390
head: git(data.source, 'rev-parse', 'HEAD') });
2385-
const keepers = () => new Set(docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.task-storage=keeper')
2386-
.split('\n').filter(Boolean));
2391+
// Only this run's keepers: the orphans found are force-removed, so another run's must never be among them.
2392+
const keepers = () => new Set(docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.task-storage=keeper',
2393+
'--filter', `label=io.codeboost.runner=${TEST_RUNNER_OWNER}`).split('\n').filter(Boolean));
23872394
const before = keepers();
23882395
const shim = join(data.root, 'docker-shim'); mkdirSync(shim);
23892396
const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim();

0 commit comments

Comments
 (0)