Skip to content

Commit 7b4e461

Browse files
authored
fix(test): clean Swift toolchain temporary directories (#1664)
* fix(test): clean Swift toolchain temporary directories * fix(test): wait for Swift toolchain shutdown * fix(test): terminate Swift toolchain process groups
1 parent e14c9d8 commit 7b4e461

4 files changed

Lines changed: 322 additions & 4 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@
101101
"build:android-ime-helper": "AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/build-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-ime-helper",
102102
"package:android-ime-helper": "AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-ime-helper",
103103
"package:android-ime-helper:npm": "rm -rf android/ime-helper/dist && AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") android/ime-helper/dist",
104-
"build:macos-helper": "swift build -c release --package-path apple/macos-helper",
105-
"build:macos-helper:clean": "swift package --package-path apple/macos-helper clean && pnpm build:macos-helper",
104+
"build:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift build -c release --package-path apple/macos-helper",
105+
"build:macos-helper:clean": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift package --package-path apple/macos-helper clean && pnpm build:macos-helper",
106106
"build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android",
107107
"package:npm": "pnpm build:package && pnpm check:package",
108108
"release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release",
@@ -139,7 +139,7 @@
139139
"check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
140140
"check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
141141
"check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
142-
"check:tmpdir-leaks:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts scripts/node-test-tmpdir.test.ts",
142+
"check:tmpdir-leaks:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts scripts/node-test-tmpdir.test.ts scripts/swift-toolchain-tmpdir.test.ts",
143143
"check:freerange": "fr",
144144
"check:quick": "pnpm lint && pnpm typecheck",
145145
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",

scripts/build-xcuitest-apple.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then
138138
SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS"
139139
fi
140140

141-
xcodebuild build-for-testing \
141+
node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \
142142
-project "$PROJECT_PATH" \
143143
-scheme "$SCHEME" \
144144
-destination "$DESTINATION" \
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { test } from 'node:test';
6+
import { fileURLToPath } from 'node:url';
7+
import { runCmd, runCmdBackground } from '../src/utils/exec.ts';
8+
9+
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10+
const WRAPPER = path.join(REPOSITORY_ROOT, 'scripts', 'swift-toolchain-tmpdir.ts');
11+
12+
async function runProbe(exitCode: number): Promise<{
13+
childTmpDir: string;
14+
resultExitCode: number;
15+
}> {
16+
const evidenceRoot = fs.mkdtempSync(
17+
path.join(os.tmpdir(), 'swift-toolchain-tmpdir-lifecycle-test-'),
18+
);
19+
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
20+
let childTmpDir: string | undefined;
21+
22+
try {
23+
const probe = `
24+
const fs = require('node:fs');
25+
const path = require('node:path');
26+
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.TMPDIR);
27+
const leaked = path.join(process.env.TMPDIR, 'TemporaryDirectory.probe');
28+
fs.mkdirSync(leaked);
29+
fs.writeFileSync(path.join(leaked, '.keep-directory'), '');
30+
process.exit(${exitCode});
31+
`;
32+
const result = await runCmd(
33+
process.execPath,
34+
['--experimental-strip-types', WRAPPER, process.execPath, '-e', probe],
35+
{
36+
cwd: REPOSITORY_ROOT,
37+
timeoutMs: 30_000,
38+
allowFailure: true,
39+
},
40+
);
41+
42+
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
43+
assert.match(path.basename(childTmpDir), /^agent-device-swift-toolchain-/);
44+
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
45+
return { childTmpDir, resultExitCode: result.exitCode };
46+
} finally {
47+
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
48+
fs.rmSync(evidenceRoot, { recursive: true, force: true });
49+
}
50+
}
51+
52+
test('the Swift toolchain wrapper removes its TMPDIR after success', async () => {
53+
const result = await runProbe(0);
54+
assert.equal(result.resultExitCode, 0);
55+
});
56+
57+
test('the Swift toolchain wrapper cleans up and forwards a failure', async () => {
58+
const result = await runProbe(17);
59+
assert.equal(result.resultExitCode, 17);
60+
});
61+
62+
test('the Swift toolchain wrapper keeps TMPDIR until a signaled child exits', async () => {
63+
const evidenceRoot = fs.mkdtempSync(
64+
path.join(os.tmpdir(), 'swift-toolchain-tmpdir-signal-test-'),
65+
);
66+
const readyPath = path.join(evidenceRoot, 'ready.json');
67+
const shutdownPath = path.join(evidenceRoot, 'shutdown.json');
68+
let childTmpDir: string | undefined;
69+
70+
try {
71+
const probe = `
72+
const fs = require('node:fs');
73+
fs.writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ tmpdir: process.env.TMPDIR }));
74+
process.on('SIGTERM', () => {
75+
setTimeout(() => {
76+
fs.writeFileSync(
77+
${JSON.stringify(shutdownPath)},
78+
JSON.stringify({ tmpdirExisted: fs.existsSync(process.env.TMPDIR) }),
79+
);
80+
process.exit(0);
81+
}, 200);
82+
});
83+
setInterval(() => {}, 1_000);
84+
`;
85+
const background = runCmdBackground(
86+
process.execPath,
87+
['--experimental-strip-types', WRAPPER, process.execPath, '-e', probe],
88+
{
89+
cwd: REPOSITORY_ROOT,
90+
allowFailure: true,
91+
},
92+
);
93+
94+
await waitForFile(readyPath);
95+
childTmpDir = (JSON.parse(fs.readFileSync(readyPath, 'utf8')) as { tmpdir: string }).tmpdir;
96+
background.child.kill('SIGTERM');
97+
98+
const result = await background.wait;
99+
assert.equal(result.exitCode, 143);
100+
assert.equal(
101+
(JSON.parse(fs.readFileSync(shutdownPath, 'utf8')) as { tmpdirExisted: boolean })
102+
.tmpdirExisted,
103+
true,
104+
'the child must retain TMPDIR until its delayed shutdown completes',
105+
);
106+
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
107+
} finally {
108+
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
109+
fs.rmSync(evidenceRoot, { recursive: true, force: true });
110+
}
111+
});
112+
113+
test(
114+
'the Swift toolchain wrapper keeps TMPDIR until signaled descendants exit',
115+
{ skip: process.platform === 'win32' },
116+
async () => {
117+
const evidenceRoot = fs.mkdtempSync(
118+
path.join(os.tmpdir(), 'swift-toolchain-tmpdir-descendant-test-'),
119+
);
120+
const readyPath = path.join(evidenceRoot, 'ready.json');
121+
const shutdownPath = path.join(evidenceRoot, 'shutdown.json');
122+
let childTmpDir: string | undefined;
123+
let descendantPid: number | undefined;
124+
125+
try {
126+
const descendantProbe = `
127+
const fs = require('node:fs');
128+
fs.writeFileSync(
129+
${JSON.stringify(readyPath)},
130+
JSON.stringify({ pid: process.pid, tmpdir: process.env.TMPDIR }),
131+
);
132+
process.on('SIGTERM', () => {
133+
setTimeout(() => {
134+
fs.writeFileSync(
135+
${JSON.stringify(shutdownPath)},
136+
JSON.stringify({ tmpdirExisted: fs.existsSync(process.env.TMPDIR) }),
137+
);
138+
process.exit(0);
139+
}, 200);
140+
});
141+
setInterval(() => {}, 1_000);
142+
`;
143+
const directChildProbe = `
144+
const { spawn } = require('node:child_process');
145+
process.on('SIGTERM', () => process.exit(0));
146+
const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantProbe)}], {
147+
env: process.env,
148+
stdio: 'ignore',
149+
});
150+
descendant.unref();
151+
setInterval(() => {}, 1_000);
152+
`;
153+
const background = runCmdBackground(
154+
process.execPath,
155+
['--experimental-strip-types', WRAPPER, process.execPath, '-e', directChildProbe],
156+
{
157+
cwd: REPOSITORY_ROOT,
158+
allowFailure: true,
159+
},
160+
);
161+
162+
await waitForFile(readyPath);
163+
const ready = JSON.parse(fs.readFileSync(readyPath, 'utf8')) as {
164+
pid: number;
165+
tmpdir: string;
166+
};
167+
descendantPid = ready.pid;
168+
childTmpDir = ready.tmpdir;
169+
background.child.kill('SIGTERM');
170+
171+
const result = await background.wait;
172+
assert.equal(result.exitCode, 143);
173+
if (!fs.existsSync(shutdownPath)) process.kill(descendantPid, 'SIGTERM');
174+
await waitForFile(shutdownPath);
175+
assert.equal(
176+
(JSON.parse(fs.readFileSync(shutdownPath, 'utf8')) as { tmpdirExisted: boolean })
177+
.tmpdirExisted,
178+
true,
179+
'a delayed descendant must retain TMPDIR until its shutdown completes',
180+
);
181+
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
182+
} finally {
183+
if (descendantPid) {
184+
try {
185+
process.kill(descendantPid, 'SIGKILL');
186+
} catch {}
187+
}
188+
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
189+
fs.rmSync(evidenceRoot, { recursive: true, force: true });
190+
}
191+
},
192+
);
193+
194+
test('Apple build lanes route toolchain commands through the cleanup wrapper', () => {
195+
const manifest = JSON.parse(
196+
fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
197+
) as { scripts?: Record<string, string> };
198+
const scripts = manifest.scripts ?? {};
199+
assert.match(scripts['build:macos-helper'] ?? '', /swift-toolchain-tmpdir\.ts.*swift build/);
200+
assert.match(
201+
scripts['build:macos-helper:clean'] ?? '',
202+
/swift-toolchain-tmpdir\.ts.*swift package/,
203+
);
204+
205+
const xcodeBuildScript = fs.readFileSync(
206+
path.join(REPOSITORY_ROOT, 'scripts', 'build-xcuitest-apple.sh'),
207+
'utf8',
208+
);
209+
assert.match(xcodeBuildScript, /swift-toolchain-tmpdir\.ts xcodebuild build-for-testing/);
210+
});
211+
212+
async function waitForFile(filePath: string): Promise<void> {
213+
for (let attempt = 0; attempt < 100; attempt += 1) {
214+
if (fs.existsSync(filePath)) return;
215+
await new Promise((resolve) => setTimeout(resolve, 10));
216+
}
217+
throw new Error(`Timed out waiting for ${filePath}`);
218+
}

scripts/swift-toolchain-tmpdir.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Xcode 26.2's SwiftPM leaves TemporaryDirectory.* marker directories behind
2+
// even after successful `swift` and `xcodebuild` commands. Keep build-lane
3+
// scratch state under one owned directory so it can be removed deterministically.
4+
// Production runner launches intentionally do not use this wrapper.
5+
import fs from 'node:fs';
6+
import os from 'node:os';
7+
import path from 'node:path';
8+
import { runCmdBackground } from '../src/utils/exec.ts';
9+
10+
const [command, ...args] = process.argv.slice(2);
11+
if (!command) {
12+
throw new Error('Usage: node scripts/swift-toolchain-tmpdir.ts <command> [args...]');
13+
}
14+
15+
const commandTmpDir = fs.mkdtempSync(
16+
path.join(os.tmpdir(), `agent-device-swift-toolchain-${process.pid}-`),
17+
);
18+
19+
let cleanedUp = false;
20+
function cleanup(): void {
21+
if (cleanedUp) return;
22+
cleanedUp = true;
23+
fs.rmSync(commandTmpDir, { recursive: true, force: true });
24+
}
25+
process.on('exit', cleanup);
26+
27+
const { child, wait } = runCmdBackground(command, args, {
28+
env: { ...process.env, TMPDIR: commandTmpDir },
29+
stdio: 'inherit',
30+
captureOutput: false,
31+
allowFailure: true,
32+
detached: process.platform !== 'win32',
33+
});
34+
35+
const FORCE_KILL_DELAY_MS = 5_000;
36+
const FORCE_KILL_WAIT_MS = 1_000;
37+
const PROCESS_GROUP_POLL_MS = 25;
38+
const processGroupId = process.platform !== 'win32' ? child.pid : undefined;
39+
let forwardedSignal: 'SIGINT' | 'SIGTERM' | undefined;
40+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
41+
let forceKillDeadline: number | undefined;
42+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
43+
process.on(signal, () => {
44+
if (forwardedSignal) return;
45+
forwardedSignal = signal;
46+
signalToolchain(signal);
47+
// Keep the owned TMPDIR alive while Swift/Xcode handles the signal and
48+
// flushes its descendants. A stuck process group is force-killed after a
49+
// bounded grace period, after which normal exit cleanup can run.
50+
forceKillDeadline = Date.now() + FORCE_KILL_DELAY_MS;
51+
forceKillTimer = setTimeout(() => signalToolchain('SIGKILL'), FORCE_KILL_DELAY_MS);
52+
forceKillTimer.unref();
53+
});
54+
}
55+
56+
const result = await wait;
57+
if (processGroupId) {
58+
const gracefulDeadline = forceKillDeadline ?? Date.now() + FORCE_KILL_DELAY_MS;
59+
const exitedGracefully = await waitForProcessGroupExit(
60+
processGroupId,
61+
Math.max(0, gracefulDeadline - Date.now()),
62+
);
63+
if (!exitedGracefully) {
64+
signalToolchain('SIGKILL');
65+
await waitForProcessGroupExit(processGroupId, FORCE_KILL_WAIT_MS);
66+
}
67+
}
68+
if (forceKillTimer) clearTimeout(forceKillTimer);
69+
process.exitCode =
70+
forwardedSignal === 'SIGINT' ? 130 : forwardedSignal === 'SIGTERM' ? 143 : result.exitCode;
71+
72+
function signalToolchain(signal: NodeJS.Signals): void {
73+
if (processGroupId) {
74+
try {
75+
process.kill(-processGroupId, signal);
76+
return;
77+
} catch (error) {
78+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return;
79+
}
80+
}
81+
child.kill(signal);
82+
}
83+
84+
async function waitForProcessGroupExit(processGroup: number, timeoutMs: number): Promise<boolean> {
85+
const deadline = Date.now() + timeoutMs;
86+
while (isProcessGroupAlive(processGroup)) {
87+
if (Date.now() >= deadline) return false;
88+
await new Promise((resolve) => setTimeout(resolve, PROCESS_GROUP_POLL_MS));
89+
}
90+
return true;
91+
}
92+
93+
function isProcessGroupAlive(processGroup: number): boolean {
94+
try {
95+
process.kill(-processGroup, 0);
96+
return true;
97+
} catch (error) {
98+
return (error as NodeJS.ErrnoException).code === 'EPERM';
99+
}
100+
}

0 commit comments

Comments
 (0)