Skip to content

Commit 89456df

Browse files
Mossakaclaude
andauthored
perf: batch chroot integration tests to reduce container overhead (#845)
* perf: batch chroot integration tests to reduce container overhead Each chroot test previously spawned a fresh Docker container pair (Squid + Agent), adding ~15-25s of overhead per test. With ~73 tests, container lifecycle alone accounted for 17-29 minutes. This introduces a batch runner utility that combines multiple commands sharing the same allowDomains config into a single AWF invocation with structured output delimiters. Individual test cases still appear in Jest output via beforeAll/test pattern. Changes: - Add tests/fixtures/batch-runner.ts: generates batched shell scripts, parses per-command results from delimited output - Refactor all 5 chroot test files to batch compatible commands: - chroot-languages: 20 → 4 invocations - chroot-edge-cases: 19 → 8 invocations - chroot-procfs: 8 → 2 invocations - chroot-package-managers: 23 → 12 invocations - chroot-copilot-home: 3 → 1 invocation - Remove needs: test-chroot-languages from procfs and edge-cases CI jobs so 3 of 4 jobs run in parallel immediately Total: ~73 → ~27 AWF invocations (~63% reduction) Estimated time saved: 11-19 minutes of container overhead Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: capture exit code before echo resets $? in batch runner The `echo ""` between the subshell and `$?` capture was resetting the exit code to 0 for every command. This caused capability-dropping tests (iptables, chroot) to appear to succeed when they actually failed. Fix: save `$?` into `_EC` immediately after the subshell exits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b65f149 commit 89456df

File tree

7 files changed

+714
-731
lines changed

7 files changed

+714
-731
lines changed

.github/workflows/test-chroot.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ jobs:
273273
name: Test Chroot /proc Filesystem
274274
runs-on: ubuntu-latest
275275
timeout-minutes: 30
276-
needs: test-chroot-languages # Run after language tests pass
276+
# No dependency on languages - runs in parallel for faster CI
277277

278278
steps:
279279
- name: Checkout repository
@@ -347,7 +347,7 @@ jobs:
347347
name: Test Chroot Edge Cases
348348
runs-on: ubuntu-latest
349349
timeout-minutes: 30
350-
needs: test-chroot-languages # Run after language tests pass
350+
# No dependency on languages - runs in parallel for faster CI
351351

352352
steps:
353353
- name: Checkout repository

tests/fixtures/batch-runner.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Batch Runner - runs multiple commands in a single AWF container invocation.
3+
*
4+
* Each test that calls runner.runWithSudo() spawns a full Docker container
5+
* lifecycle (~15-25s overhead). This utility batches commands that share the
6+
* same allowDomains config into one invocation, cutting container startups
7+
* from ~73 to ~27 across the chroot test suite.
8+
*
9+
* Usage:
10+
* const results = await runBatch(runner, [
11+
* { name: 'python_version', command: 'python3 --version' },
12+
* { name: 'node_version', command: 'node --version' },
13+
* ], { allowDomains: ['github.com'] });
14+
*
15+
* // Each test asserts against its own result:
16+
* expect(results.get('python_version').exitCode).toBe(0);
17+
*/
18+
19+
import { AwfRunner, AwfOptions, AwfResult } from './awf-runner';
20+
21+
export interface BatchCommand {
22+
name: string;
23+
command: string;
24+
}
25+
26+
export interface BatchCommandResult {
27+
stdout: string;
28+
exitCode: number;
29+
}
30+
31+
export interface BatchResults {
32+
/** Get result for a named command. Throws if name not found. */
33+
get(name: string): BatchCommandResult;
34+
/** The raw AWF result for the entire batch invocation. */
35+
overall: AwfResult;
36+
}
37+
38+
// Delimiter tokens – chosen to be unlikely in real command output
39+
const START = '===BATCH_START:';
40+
const EXIT = '===BATCH_EXIT:';
41+
const DELIM_END = '===';
42+
43+
/**
44+
* Build a bash script that runs each command in a subshell, capturing its
45+
* exit code and delimiting its output.
46+
*/
47+
function generateScript(commands: BatchCommand[]): string {
48+
return commands.map(cmd => {
49+
// Each command runs in a subshell so failures don't abort the batch.
50+
// stdout and stderr are merged (2>&1) so we capture everything.
51+
// IMPORTANT: capture $? immediately into _EC before echo resets it.
52+
return [
53+
`echo "${START}${cmd.name}${DELIM_END}"`,
54+
`(${cmd.command}) 2>&1`,
55+
`_EC=$?`,
56+
`echo ""`,
57+
`echo "${EXIT}${cmd.name}:$_EC${DELIM_END}"`,
58+
].join('; ');
59+
}).join('; ');
60+
}
61+
62+
/**
63+
* Parse the combined stdout into per-command results.
64+
*/
65+
function parseResults(stdout: string, commands: BatchCommand[]): Map<string, BatchCommandResult> {
66+
const results = new Map<string, BatchCommandResult>();
67+
68+
for (const cmd of commands) {
69+
const startToken = `${START}${cmd.name}${DELIM_END}`;
70+
const exitPattern = new RegExp(`${EXIT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${cmd.name}:(\\d+)${DELIM_END}`);
71+
72+
const startIdx = stdout.indexOf(startToken);
73+
const exitMatch = stdout.match(exitPattern);
74+
75+
if (startIdx === -1 || !exitMatch) {
76+
// Command output not found – likely the batch was killed early
77+
results.set(cmd.name, { stdout: '', exitCode: -1 });
78+
continue;
79+
}
80+
81+
const contentStart = startIdx + startToken.length;
82+
const contentEnd = stdout.indexOf(exitMatch[0], contentStart) - 1; // -1 for the blank line
83+
const cmdStdout = stdout.slice(contentStart, contentEnd).trim();
84+
const exitCode = parseInt(exitMatch[1], 10);
85+
86+
results.set(cmd.name, { stdout: cmdStdout, exitCode });
87+
}
88+
89+
return results;
90+
}
91+
92+
/**
93+
* Run multiple commands in a single AWF container invocation.
94+
*
95+
* All commands share the same AwfOptions (allowDomains, timeout, etc.).
96+
* Individual command results are parsed from delimited output.
97+
*/
98+
export async function runBatch(
99+
runner: AwfRunner,
100+
commands: BatchCommand[],
101+
options: AwfOptions,
102+
): Promise<BatchResults> {
103+
const script = generateScript(commands);
104+
const result = await runner.runWithSudo(script, options);
105+
const parsed = parseResults(result.stdout, commands);
106+
107+
return {
108+
get(name: string): BatchCommandResult {
109+
const r = parsed.get(name);
110+
if (!r) {
111+
throw new Error(`Batch command "${name}" not found in results. Available: ${[...parsed.keys()].join(', ')}`);
112+
}
113+
return r;
114+
},
115+
overall: result,
116+
};
117+
}

tests/integration/chroot-copilot-home.test.ts

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,76 +9,74 @@
99
*
1010
* The fix mounts ~/.copilot at /host~/.copilot in chroot mode to enable
1111
* write access while maintaining security (no full HOME mount).
12+
*
13+
* OPTIMIZATION: All 3 tests share the same allowDomains and are batched
14+
* into a single AWF invocation. Reduces 3 invocations to 1.
1215
*/
1316

1417
/// <reference path="../jest-custom-matchers.d.ts" />
1518

1619
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
1720
import { createRunner, AwfRunner } from '../fixtures/awf-runner';
1821
import { cleanup } from '../fixtures/cleanup';
22+
import { runBatch, BatchResults } from '../fixtures/batch-runner';
1923
import * as fs from 'fs';
2024
import * as os from 'os';
2125
import * as path from 'path';
2226

2327
describe('Chroot Copilot Home Directory Access', () => {
2428
let runner: AwfRunner;
25-
let testCopilotDir: string;
29+
let batch: BatchResults;
2630

2731
beforeAll(async () => {
2832
await cleanup(false);
2933
runner = createRunner();
30-
34+
3135
// Ensure ~/.copilot exists on the host (as the workflow does)
32-
testCopilotDir = path.join(os.homedir(), '.copilot');
36+
const testCopilotDir = path.join(os.homedir(), '.copilot');
3337
if (!fs.existsSync(testCopilotDir)) {
3438
fs.mkdirSync(testCopilotDir, { recursive: true, mode: 0o755 });
3539
}
36-
});
37-
38-
afterAll(async () => {
39-
await cleanup(false);
40-
});
4140

42-
test('should be able to write to ~/.copilot directory', async () => {
43-
const result = await runner.runWithSudo(
44-
'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt',
41+
batch = await runBatch(runner, [
4542
{
46-
allowDomains: ['localhost'],
47-
logLevel: 'debug',
48-
timeout: 60000,
49-
}
50-
);
51-
52-
expect(result).toSucceed();
53-
expect(result.stdout).toContain('test-content');
43+
name: 'write_file',
44+
command: 'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt',
45+
},
46+
{
47+
name: 'nested_dirs',
48+
command: 'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt',
49+
},
50+
{
51+
name: 'permissions',
52+
command: 'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"',
53+
},
54+
], {
55+
allowDomains: ['localhost'],
56+
logLevel: 'debug',
57+
timeout: 60000,
58+
});
5459
}, 120000);
5560

56-
test('should be able to create nested directories in ~/.copilot', async () => {
57-
// Simulate what Copilot CLI does: create pkg/linux-x64/VERSION
58-
const result = await runner.runWithSudo(
59-
'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt',
60-
{
61-
allowDomains: ['localhost'],
62-
logLevel: 'debug',
63-
timeout: 60000,
64-
}
65-
);
61+
afterAll(async () => {
62+
await cleanup(false);
63+
});
6664

67-
expect(result).toSucceed();
68-
expect(result.stdout).toContain('package-extracted');
69-
}, 120000);
65+
test('should be able to write to ~/.copilot directory', () => {
66+
const r = batch.get('write_file');
67+
expect(r.exitCode).toBe(0);
68+
expect(r.stdout).toContain('test-content');
69+
});
7070

71-
test('should verify ~/.copilot is writable with correct permissions', async () => {
72-
const result = await runner.runWithSudo(
73-
'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"',
74-
{
75-
allowDomains: ['localhost'],
76-
logLevel: 'debug',
77-
timeout: 60000,
78-
}
79-
);
71+
test('should be able to create nested directories in ~/.copilot', () => {
72+
const r = batch.get('nested_dirs');
73+
expect(r.exitCode).toBe(0);
74+
expect(r.stdout).toContain('package-extracted');
75+
});
8076

81-
expect(result).toSucceed();
82-
expect(result.stdout).toContain('write-success');
83-
}, 120000);
77+
test('should verify ~/.copilot is writable with correct permissions', () => {
78+
const r = batch.get('permissions');
79+
expect(r.exitCode).toBe(0);
80+
expect(r.stdout).toContain('write-success');
81+
});
8482
});

0 commit comments

Comments
 (0)