Skip to content

Commit 3326798

Browse files
brandonpaytonadamziel
authored andcommitted
[CLI] Fix temp dir cleanup when Playground CLI server is killed (#2836)
## Motivation for the change, related issues Today, when Playground CLI is killed via Ctrl-C (SIGINT), the temp dir is left behind. This is a problem because Playground CLI server runs until it is killed in such a manner. ## Implementation details This PR adds explicit temp dir cleanup when receiving the signals SIGINT or SIGTERM. ## Testing Instructions (or ideally a Blueprint) - Run Playground CLI server in the console with the `--verbose=debug` flag. - Note the temp dir it created and printed in the CLI output - Kill the server with Ctrl-C - Confirm that the director no longer exists Prior to this patch, the temp dir still exists after this test. After this patch, the temp dir is removed.
1 parent 2514ba6 commit 3326798

File tree

6 files changed

+122
-83
lines changed

6 files changed

+122
-83
lines changed

packages/playground/cli/src/run-cli.ts

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,33 @@ export async function parseOptionsAndRunCLI() {
394394
],
395395
} as RunCLIArgs;
396396

397-
await runCLI(cliArgs);
397+
const cliServer = await runCLI(cliArgs);
398+
if (cliServer === undefined) {
399+
// No server was started, so we are done with our work.
400+
process.exit(0);
401+
}
402+
403+
const cleanUpCliAndExit = (() => {
404+
// Remember we are already cleaning up to preclude the possibility
405+
// of multiple, conflicting cleanup attempts.
406+
let promiseToCleanup: Promise<void>;
407+
408+
return async () => {
409+
if (promiseToCleanup !== undefined) {
410+
promiseToCleanup = cliServer[Symbol.asyncDispose]();
411+
}
412+
await promiseToCleanup;
413+
process.exit(0);
414+
};
415+
})();
416+
417+
// Playground CLI server must be killed to exit. From the terminal,
418+
// this may occur via Ctrl+C which sends SIGINT. Let's handle both
419+
// SIGINT and SIGTERM (the default kill signal) to make sure we
420+
// clean up after ourselves even if this process is being killed.
421+
// NOTE: Windows does not support SIGTERM, but Node.js provides some emulation.
422+
process.on('SIGINT', cleanUpCliAndExit);
423+
process.on('SIGTERM', cleanUpCliAndExit);
398424
} catch (e) {
399425
if (!(e instanceof Error)) {
400426
throw e;
@@ -437,7 +463,6 @@ export interface RunCLIArgs {
437463
autoMount?: string;
438464
experimentalMultiWorker?: number;
439465
experimentalTrace?: boolean;
440-
exitOnPrimaryWorkerCrash?: boolean;
441466
internalCookieStore?: boolean;
442467
'additional-blueprint-steps'?: any[];
443468
xdebug?: boolean | { ideKey?: string };
@@ -492,7 +517,17 @@ const italic = (text: string) =>
492517
const highlight = (text: string) =>
493518
process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text;
494519

495-
export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
520+
// These overloads are declared for convenience so runCLI() can return
521+
// different things depending on the CLI command without forcing the
522+
// callers (mostly automated tests) to check return values.
523+
export async function runCLI(
524+
args: RunCLIArgs & { command: 'build-snapshot' | 'run-blueprint' }
525+
): Promise<void>;
526+
export async function runCLI(
527+
args: RunCLIArgs & { command: 'server' }
528+
): Promise<RunCLIServer>;
529+
export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void>;
530+
export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> {
496531
let loadBalancer: LoadBalancer;
497532
let playground: RemoteAPI<PlaygroundCliWorker>;
498533

@@ -562,7 +597,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
562597

563598
return startServer({
564599
port: args['port'] as number,
565-
onBind: async (server: Server, port: number): Promise<RunCLIServer> => {
600+
onBind: async (server: Server, port: number) => {
566601
const host = '127.0.0.1';
567602
const serverUrl = `http://${host}:${port}`;
568603
const siteUrl = args['site-url'] || serverUrl;
@@ -584,10 +619,10 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
584619
* because we don't have to create or maintain multiple copies of the same files.
585620
*/
586621
const tempDirNameDelimiter = '-playground-cli-site-';
587-
const nativeDirPath = await createPlaygroundCliTempDir(
622+
const nativeDir = await createPlaygroundCliTempDir(
588623
tempDirNameDelimiter
589624
);
590-
logger.debug(`Native temp dir for VFS root: ${nativeDirPath}`);
625+
logger.debug(`Native temp dir for VFS root: ${nativeDir.path}`);
591626

592627
const IDEConfigName = 'WP Playground CLI - Listen for Xdebug';
593628

@@ -602,7 +637,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
602637
// directory and add the new IDE config.
603638
if (args.xdebug && args.experimentalUnsafeIdeIntegration) {
604639
await createPlaygroundCliTempDirSymlink(
605-
nativeDirPath,
640+
nativeDir.path,
606641
symlinkPath,
607642
process.platform
608643
);
@@ -696,17 +731,15 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
696731

697732
console.log('');
698733
} catch (error) {
699-
logger.error(
700-
'Could not configure Xdebug:',
701-
(error as Error)?.message
702-
);
703-
process.exit(1);
734+
throw new Error('Could not configure Xdebug', {
735+
cause: error,
736+
});
704737
}
705738
}
706739

707740
// We do not know the system temp dir,
708741
// but we can try to infer from the location of the current temp dir.
709-
const tempDirRoot = path.dirname(nativeDirPath);
742+
const tempDirRoot = path.dirname(nativeDir.path);
710743

711744
const twoDaysInMillis = 2 * 24 * 60 * 60 * 1000;
712745
const tempDirStaleAgeInMillis = twoDaysInMillis;
@@ -721,7 +754,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
721754

722755
// NOTE: We do not add mount declarations for /internal here
723756
// because it will be mounted as part of php-wasm init.
724-
const nativeInternalDirPath = path.join(nativeDirPath, 'internal');
757+
const nativeInternalDirPath = path.join(nativeDir.path, 'internal');
725758
mkdirSync(nativeInternalDirPath);
726759

727760
const userProvidableNativeSubdirs = [
@@ -746,7 +779,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
746779
// The user hasn't requested mounting a different native dir for this path,
747780
// so let's create a mount from within our native temp dir.
748781
const nativeSubdirPath = path.join(
749-
nativeDirPath,
782+
nativeDir.path,
750783
subdirName
751784
);
752785
mkdirSync(nativeSubdirPath);
@@ -799,26 +832,48 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
799832
}
800833
}
801834

835+
// Remember whether we are already disposing so we can avoid:
836+
// - we can avoid multiple, conflicting dispose attempts
837+
// - logging that a worker exited while the CLI itself is exiting
838+
let disposing = false;
839+
const disposeCLI = async function disposeCLI() {
840+
if (disposing) {
841+
return;
842+
}
843+
844+
disposing = true;
845+
await Promise.all(
846+
playgroundsToCleanUp.map(async ({ playground, worker }) => {
847+
await playground.dispose();
848+
await worker.terminate();
849+
})
850+
);
851+
if (server) {
852+
await new Promise((resolve) => server.close(resolve));
853+
}
854+
await nativeDir.cleanup();
855+
};
856+
802857
// Kick off worker threads now to save time later.
803858
// There is no need to wait for other async processes to complete.
804859
const promisedWorkers = spawnWorkerThreads(
805860
totalWorkerCount,
806861
handler.getWorkerType(),
807-
({ exitCode, isMain, workerIndex }) => {
808-
if (exitCode === 0) {
862+
({ exitCode, workerIndex }) => {
863+
// We are already disposing, so worker exit is expected
864+
// and does not need to be logged.
865+
if (disposing) {
866+
return;
867+
}
868+
869+
if (exitCode !== 0) {
809870
return;
810871
}
872+
811873
logger.error(
812874
`Worker ${workerIndex} exited with code ${exitCode}\n`
813875
);
814-
// If the primary worker crashes, exit the entire process.
815-
if (!isMain) {
816-
return;
817-
}
818-
if (!args.exitOnPrimaryWorkerCrash) {
819-
return;
820-
}
821-
process.exit(1);
876+
// @TODO: Should we respawn the worker if it exited with an error and the CLI is not shutting down?
822877
}
823878
);
824879

@@ -869,10 +924,12 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
869924
if (args.command === 'build-snapshot') {
870925
await zipSite(playground, args.outfile as string);
871926
logger.log(`WordPress exported to ${args.outfile}`);
872-
process.exit(0);
927+
await disposeCLI();
928+
return;
873929
} else if (args.command === 'run-blueprint') {
874930
logger.log(`Blueprint executed`);
875-
process.exit(0);
931+
await disposeCLI();
932+
return;
876933
}
877934

878935
if (
@@ -927,17 +984,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
927984
playground,
928985
server,
929986
serverUrl,
930-
[Symbol.asyncDispose]: async function disposeCLI() {
931-
await Promise.all(
932-
playgroundsToCleanUp.map(
933-
async ({ playground, worker }) => {
934-
await playground.dispose();
935-
await worker.terminate();
936-
}
937-
)
938-
);
939-
await new Promise((resolve) => server.close(resolve));
940-
},
987+
[Symbol.asyncDispose]: disposeCLI,
941988
workerThreadCount: totalWorkerCount,
942989
};
943990
} catch (error) {
@@ -993,19 +1040,14 @@ export type SpawnedWorker = {
9931040
async function spawnWorkerThreads(
9941041
count: number,
9951042
workerType: WorkerType,
996-
onWorkerExit: (options: {
997-
exitCode: number;
998-
isMain: boolean;
999-
workerIndex: number;
1000-
}) => void
1043+
onWorkerExit: (options: { exitCode: number; workerIndex: number }) => void
10011044
): Promise<SpawnedWorker[]> {
10021045
const promises = [];
10031046
for (let i = 0; i < count; i++) {
10041047
const worker = await spawnWorkerThread(workerType);
10051048
const onExit: (code: number) => void = (code: number) => {
10061049
onWorkerExit({
10071050
exitCode: code,
1008-
isMain: i === 0,
10091051
workerIndex: i,
10101052
});
10111053
};

packages/playground/cli/src/start-server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import { logger } from '@php-wasm/logger';
88

99
export interface ServerOptions {
1010
port: number;
11-
onBind: (server: Server, port: number) => Promise<RunCLIServer>;
11+
onBind: (server: Server, port: number) => Promise<RunCLIServer | void>;
1212
handleRequest: (request: PHPRequest) => Promise<PHPResponse>;
1313
}
1414

1515
export async function startServer(
1616
options: ServerOptions
17-
): Promise<RunCLIServer> {
17+
): Promise<RunCLIServer | void> {
1818
const app = express();
1919

2020
const server = await new Promise<

packages/playground/cli/src/temp-dir.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,25 @@ export async function createPlaygroundCliTempDir(
3232
// Otherwise, we would have to parse the binary name from the full path.
3333
const tempDirPrefix = `${nodeBinaryName}${substrToIdentifyTempDirs}${process.pid}-`;
3434

35-
const nativeDirPath = (
36-
await tmpDir({
37-
prefix: tempDirPrefix,
38-
/*
39-
* Allow recursive cleanup on process exit.
40-
*
41-
* NOTE: I worried about whether this cleanup would follow symlinks
42-
* and delete target files instead of unlinking the symlink,
43-
* but this feature uses rimraf under the hood which respects symlinks:
44-
* https://github.com/raszi/node-tmp/blob/3d2fe387f3f91b13830b9182faa02c3231ea8258/lib/tmp.js#L318
45-
*/
46-
unsafeCleanup: true,
47-
})
48-
).path;
35+
const nativeDir = await tmpDir({
36+
prefix: tempDirPrefix,
37+
/*
38+
* Allow recursive cleanup on process exit.
39+
*
40+
* NOTE: I worried about whether this cleanup would follow symlinks
41+
* and delete target files instead of unlinking the symlink,
42+
* but this feature uses rimraf under the hood which respects symlinks:
43+
* https://github.com/raszi/node-tmp/blob/3d2fe387f3f91b13830b9182faa02c3231ea8258/lib/tmp.js#L318
44+
*/
45+
unsafeCleanup: true,
46+
});
4947

5048
if (autoCleanup) {
5149
// Request graceful cleanup on process exit.
5250
tmpSetGracefulCleanup();
5351
}
5452

55-
return nativeDirPath;
53+
return nativeDir;
5654
}
5755

5856
/**

packages/playground/cli/tests/temp-dir-test-process.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ process.on('message', async (message: any) => {
1616
);
1717
// Add a file to the temp dir to test that cleanup works
1818
// on non-empty dirs.
19-
fs.writeFileSync(path.join(tempDir, 'test.txt'), 'test');
19+
fs.writeFileSync(path.join(tempDir.path, 'test.txt'), 'test');
2020
process.send!({
2121
type: 'temp-dir',
22-
tempDir,
22+
tempDirPath: tempDir.path,
2323
});
2424
} else if (message.type === 'exit') {
2525
process.exit(0);

0 commit comments

Comments
 (0)