mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
e14c9d8d7b
commit
7b4e461220
+3
-3
@@ -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",
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user