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
This commit is contained in:
Michał Pierzchała
2026-08-07 16:14:37 +02:00
committed by GitHub
parent e14c9d8d7b
commit 7b4e461220
4 changed files with 322 additions and 4 deletions
+3 -3
View File
@@ -101,8 +101,8 @@
"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", "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",
"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", "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",
"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", "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",
"build:macos-helper": "swift build -c release --package-path apple/macos-helper", "build:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift build -c release --package-path apple/macos-helper",
"build:macos-helper:clean": "swift package --package-path apple/macos-helper clean && pnpm build:macos-helper", "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",
"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", "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",
"package:npm": "pnpm build:package && pnpm check:package", "package:npm": "pnpm build:package && pnpm check:package",
"release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release", "release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release",
@@ -139,7 +139,7 @@
"check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts", "check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
"check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts", "check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
"check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts", "check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
"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", "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",
"check:freerange": "fr", "check:freerange": "fr",
"check:quick": "pnpm lint && pnpm typecheck", "check:quick": "pnpm lint && pnpm typecheck",
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
+1 -1
View File
@@ -138,7 +138,7 @@ if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then
SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS" SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS"
fi fi
xcodebuild build-for-testing \ node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \
-project "$PROJECT_PATH" \ -project "$PROJECT_PATH" \
-scheme "$SCHEME" \ -scheme "$SCHEME" \
-destination "$DESTINATION" \ -destination "$DESTINATION" \
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { runCmd, runCmdBackground } from '../src/utils/exec.ts';
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WRAPPER = path.join(REPOSITORY_ROOT, 'scripts', 'swift-toolchain-tmpdir.ts');
async function runProbe(exitCode: number): Promise<{
childTmpDir: string;
resultExitCode: number;
}> {
const evidenceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'swift-toolchain-tmpdir-lifecycle-test-'),
);
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
let childTmpDir: string | undefined;
try {
const probe = `
const fs = require('node:fs');
const path = require('node:path');
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.TMPDIR);
const leaked = path.join(process.env.TMPDIR, 'TemporaryDirectory.probe');
fs.mkdirSync(leaked);
fs.writeFileSync(path.join(leaked, '.keep-directory'), '');
process.exit(${exitCode});
`;
const result = await runCmd(
process.execPath,
['--experimental-strip-types', WRAPPER, process.execPath, '-e', probe],
{
cwd: REPOSITORY_ROOT,
timeoutMs: 30_000,
allowFailure: true,
},
);
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
assert.match(path.basename(childTmpDir), /^agent-device-swift-toolchain-/);
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
return { childTmpDir, resultExitCode: result.exitCode };
} finally {
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
}
test('the Swift toolchain wrapper removes its TMPDIR after success', async () => {
const result = await runProbe(0);
assert.equal(result.resultExitCode, 0);
});
test('the Swift toolchain wrapper cleans up and forwards a failure', async () => {
const result = await runProbe(17);
assert.equal(result.resultExitCode, 17);
});
test('the Swift toolchain wrapper keeps TMPDIR until a signaled child exits', async () => {
const evidenceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'swift-toolchain-tmpdir-signal-test-'),
);
const readyPath = path.join(evidenceRoot, 'ready.json');
const shutdownPath = path.join(evidenceRoot, 'shutdown.json');
let childTmpDir: string | undefined;
try {
const probe = `
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ tmpdir: process.env.TMPDIR }));
process.on('SIGTERM', () => {
setTimeout(() => {
fs.writeFileSync(
${JSON.stringify(shutdownPath)},
JSON.stringify({ tmpdirExisted: fs.existsSync(process.env.TMPDIR) }),
);
process.exit(0);
}, 200);
});
setInterval(() => {}, 1_000);
`;
const background = runCmdBackground(
process.execPath,
['--experimental-strip-types', WRAPPER, process.execPath, '-e', probe],
{
cwd: REPOSITORY_ROOT,
allowFailure: true,
},
);
await waitForFile(readyPath);
childTmpDir = (JSON.parse(fs.readFileSync(readyPath, 'utf8')) as { tmpdir: string }).tmpdir;
background.child.kill('SIGTERM');
const result = await background.wait;
assert.equal(result.exitCode, 143);
assert.equal(
(JSON.parse(fs.readFileSync(shutdownPath, 'utf8')) as { tmpdirExisted: boolean })
.tmpdirExisted,
true,
'the child must retain TMPDIR until its delayed shutdown completes',
);
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
} finally {
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
test(
'the Swift toolchain wrapper keeps TMPDIR until signaled descendants exit',
{ skip: process.platform === 'win32' },
async () => {
const evidenceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'swift-toolchain-tmpdir-descendant-test-'),
);
const readyPath = path.join(evidenceRoot, 'ready.json');
const shutdownPath = path.join(evidenceRoot, 'shutdown.json');
let childTmpDir: string | undefined;
let descendantPid: number | undefined;
try {
const descendantProbe = `
const fs = require('node:fs');
fs.writeFileSync(
${JSON.stringify(readyPath)},
JSON.stringify({ pid: process.pid, tmpdir: process.env.TMPDIR }),
);
process.on('SIGTERM', () => {
setTimeout(() => {
fs.writeFileSync(
${JSON.stringify(shutdownPath)},
JSON.stringify({ tmpdirExisted: fs.existsSync(process.env.TMPDIR) }),
);
process.exit(0);
}, 200);
});
setInterval(() => {}, 1_000);
`;
const directChildProbe = `
const { spawn } = require('node:child_process');
process.on('SIGTERM', () => process.exit(0));
const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantProbe)}], {
env: process.env,
stdio: 'ignore',
});
descendant.unref();
setInterval(() => {}, 1_000);
`;
const background = runCmdBackground(
process.execPath,
['--experimental-strip-types', WRAPPER, process.execPath, '-e', directChildProbe],
{
cwd: REPOSITORY_ROOT,
allowFailure: true,
},
);
await waitForFile(readyPath);
const ready = JSON.parse(fs.readFileSync(readyPath, 'utf8')) as {
pid: number;
tmpdir: string;
};
descendantPid = ready.pid;
childTmpDir = ready.tmpdir;
background.child.kill('SIGTERM');
const result = await background.wait;
assert.equal(result.exitCode, 143);
if (!fs.existsSync(shutdownPath)) process.kill(descendantPid, 'SIGTERM');
await waitForFile(shutdownPath);
assert.equal(
(JSON.parse(fs.readFileSync(shutdownPath, 'utf8')) as { tmpdirExisted: boolean })
.tmpdirExisted,
true,
'a delayed descendant must retain TMPDIR until its shutdown completes',
);
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
} finally {
if (descendantPid) {
try {
process.kill(descendantPid, 'SIGKILL');
} catch {}
}
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
},
);
test('Apple build lanes route toolchain commands through the cleanup wrapper', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const scripts = manifest.scripts ?? {};
assert.match(scripts['build:macos-helper'] ?? '', /swift-toolchain-tmpdir\.ts.*swift build/);
assert.match(
scripts['build:macos-helper:clean'] ?? '',
/swift-toolchain-tmpdir\.ts.*swift package/,
);
const xcodeBuildScript = fs.readFileSync(
path.join(REPOSITORY_ROOT, 'scripts', 'build-xcuitest-apple.sh'),
'utf8',
);
assert.match(xcodeBuildScript, /swift-toolchain-tmpdir\.ts xcodebuild build-for-testing/);
});
async function waitForFile(filePath: string): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (fs.existsSync(filePath)) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for ${filePath}`);
}
+100
View File
@@ -0,0 +1,100 @@
// Xcode 26.2's SwiftPM leaves TemporaryDirectory.* marker directories behind
// even after successful `swift` and `xcodebuild` commands. Keep build-lane
// scratch state under one owned directory so it can be removed deterministically.
// Production runner launches intentionally do not use this wrapper.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runCmdBackground } from '../src/utils/exec.ts';
const [command, ...args] = process.argv.slice(2);
if (!command) {
throw new Error('Usage: node scripts/swift-toolchain-tmpdir.ts <command> [args...]');
}
const commandTmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `agent-device-swift-toolchain-${process.pid}-`),
);
let cleanedUp = false;
function cleanup(): void {
if (cleanedUp) return;
cleanedUp = true;
fs.rmSync(commandTmpDir, { recursive: true, force: true });
}
process.on('exit', cleanup);
const { child, wait } = runCmdBackground(command, args, {
env: { ...process.env, TMPDIR: commandTmpDir },
stdio: 'inherit',
captureOutput: false,
allowFailure: true,
detached: process.platform !== 'win32',
});
const FORCE_KILL_DELAY_MS = 5_000;
const FORCE_KILL_WAIT_MS = 1_000;
const PROCESS_GROUP_POLL_MS = 25;
const processGroupId = process.platform !== 'win32' ? child.pid : undefined;
let forwardedSignal: 'SIGINT' | 'SIGTERM' | undefined;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
let forceKillDeadline: number | undefined;
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
if (forwardedSignal) return;
forwardedSignal = signal;
signalToolchain(signal);
// Keep the owned TMPDIR alive while Swift/Xcode handles the signal and
// flushes its descendants. A stuck process group is force-killed after a
// bounded grace period, after which normal exit cleanup can run.
forceKillDeadline = Date.now() + FORCE_KILL_DELAY_MS;
forceKillTimer = setTimeout(() => signalToolchain('SIGKILL'), FORCE_KILL_DELAY_MS);
forceKillTimer.unref();
});
}
const result = await wait;
if (processGroupId) {
const gracefulDeadline = forceKillDeadline ?? Date.now() + FORCE_KILL_DELAY_MS;
const exitedGracefully = await waitForProcessGroupExit(
processGroupId,
Math.max(0, gracefulDeadline - Date.now()),
);
if (!exitedGracefully) {
signalToolchain('SIGKILL');
await waitForProcessGroupExit(processGroupId, FORCE_KILL_WAIT_MS);
}
}
if (forceKillTimer) clearTimeout(forceKillTimer);
process.exitCode =
forwardedSignal === 'SIGINT' ? 130 : forwardedSignal === 'SIGTERM' ? 143 : result.exitCode;
function signalToolchain(signal: NodeJS.Signals): void {
if (processGroupId) {
try {
process.kill(-processGroupId, signal);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return;
}
}
child.kill(signal);
}
async function waitForProcessGroupExit(processGroup: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (isProcessGroupAlive(processGroup)) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, PROCESS_GROUP_POLL_MS));
}
return true;
}
function isProcessGroupAlive(processGroup: number): boolean {
try {
process.kill(-processGroup, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}