-
Notifications
You must be signed in to change notification settings - Fork 361
[CLI] Fix temp dir cleanup when Playground CLI server is killed #2836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
734088d
021fc02
bfc4fa4
d2dba29
39f4f5f
71b1595
27aba3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -394,7 +394,33 @@ export async function parseOptionsAndRunCLI() { | |||||||||
| ], | ||||||||||
| } as RunCLIArgs; | ||||||||||
|
|
||||||||||
| await runCLI(cliArgs); | ||||||||||
| const cliServer = await runCLI(cliArgs); | ||||||||||
| if (cliServer === undefined) { | ||||||||||
| // No server was started, so we are done with our work. | ||||||||||
| process.exit(0); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const cleanUpCliAndExit = (() => { | ||||||||||
| // Remember we are already cleaning up to preclude the possibility | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||||||||||
| // of multiple, conflicting cleanup attempts. | ||||||||||
| let promiseToCleanup: Promise<void>; | ||||||||||
|
|
||||||||||
| return async () => { | ||||||||||
| if (promiseToCleanup !== undefined) { | ||||||||||
| promiseToCleanup = cliServer[Symbol.asyncDispose](); | ||||||||||
| } | ||||||||||
| await promiseToCleanup; | ||||||||||
| process.exit(0); | ||||||||||
| }; | ||||||||||
| })(); | ||||||||||
|
|
||||||||||
| // Playground CLI server must be killed to exit. From the terminal, | ||||||||||
| // this may occur via Ctrl+C which sends SIGINT. Let's handle both | ||||||||||
| // SIGINT and SIGTERM (the default kill signal) to make sure we | ||||||||||
| // clean up after ourselves even if this process is being killed. | ||||||||||
| // NOTE: Windows does not support SIGTERM, but Node.js provides some emulation. | ||||||||||
| process.on('SIGINT', cleanUpCliAndExit); | ||||||||||
| process.on('SIGTERM', cleanUpCliAndExit); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice. I just confirmed 'SIGKILL' cannot have a listener installed. Would
|
||||||||||
| } catch (e) { | ||||||||||
| if (!(e instanceof Error)) { | ||||||||||
| throw e; | ||||||||||
|
|
@@ -437,7 +463,6 @@ export interface RunCLIArgs { | |||||||||
| autoMount?: string; | ||||||||||
| experimentalMultiWorker?: number; | ||||||||||
| experimentalTrace?: boolean; | ||||||||||
| exitOnPrimaryWorkerCrash?: boolean; | ||||||||||
| internalCookieStore?: boolean; | ||||||||||
| 'additional-blueprint-steps'?: any[]; | ||||||||||
| xdebug?: boolean | { ideKey?: string }; | ||||||||||
|
|
@@ -492,7 +517,17 @@ const italic = (text: string) => | |||||||||
| const highlight = (text: string) => | ||||||||||
| process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text; | ||||||||||
|
|
||||||||||
| export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | ||||||||||
| // These overloads are declared for convenience so runCLI() can return | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good idea, thank you |
||||||||||
| // different things depending on the CLI command without forcing the | ||||||||||
| // callers (mostly automated tests) to check return values. | ||||||||||
| export async function runCLI( | ||||||||||
| args: RunCLIArgs & { command: 'build-snapshot' | 'run-blueprint' } | ||||||||||
| ): Promise<void>; | ||||||||||
| export async function runCLI( | ||||||||||
| args: RunCLIArgs & { command: 'server' } | ||||||||||
| ): Promise<RunCLIServer>; | ||||||||||
| export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void>; | ||||||||||
|
Comment on lines
+523
to
+529
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These overloads are declared for convenience so There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ^ Will add this as an inline comment. |
||||||||||
| export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> { | ||||||||||
| let loadBalancer: LoadBalancer; | ||||||||||
| let playground: RemoteAPI<PlaygroundCliWorker>; | ||||||||||
|
|
||||||||||
|
|
@@ -562,7 +597,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
|
|
||||||||||
| return startServer({ | ||||||||||
| port: args['port'] as number, | ||||||||||
| onBind: async (server: Server, port: number): Promise<RunCLIServer> => { | ||||||||||
| onBind: async (server: Server, port: number) => { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The type is removed here because it is easier if TypeScript to infers it. There should be little risk. What this handler returns becomes the return value of |
||||||||||
| const host = '127.0.0.1'; | ||||||||||
| const serverUrl = `http://${host}:${port}`; | ||||||||||
| const siteUrl = args['site-url'] || serverUrl; | ||||||||||
|
|
@@ -584,10 +619,10 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
| * because we don't have to create or maintain multiple copies of the same files. | ||||||||||
| */ | ||||||||||
| const tempDirNameDelimiter = '-playground-cli-site-'; | ||||||||||
| const nativeDirPath = await createPlaygroundCliTempDir( | ||||||||||
| const nativeDir = await createPlaygroundCliTempDir( | ||||||||||
| tempDirNameDelimiter | ||||||||||
| ); | ||||||||||
| logger.debug(`Native temp dir for VFS root: ${nativeDirPath}`); | ||||||||||
| logger.debug(`Native temp dir for VFS root: ${nativeDir.path}`); | ||||||||||
|
|
||||||||||
| const IDEConfigName = 'WP Playground CLI - Listen for Xdebug'; | ||||||||||
|
|
||||||||||
|
|
@@ -602,7 +637,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
| // directory and add the new IDE config. | ||||||||||
| if (args.xdebug && args.experimentalUnsafeIdeIntegration) { | ||||||||||
| await createPlaygroundCliTempDirSymlink( | ||||||||||
| nativeDirPath, | ||||||||||
| nativeDir.path, | ||||||||||
| symlinkPath, | ||||||||||
| process.platform | ||||||||||
| ); | ||||||||||
|
|
@@ -696,17 +731,15 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
|
|
||||||||||
| console.log(''); | ||||||||||
| } catch (error) { | ||||||||||
| logger.error( | ||||||||||
| 'Could not configure Xdebug:', | ||||||||||
| (error as Error)?.message | ||||||||||
| ); | ||||||||||
| process.exit(1); | ||||||||||
| throw new Error('Could not configure Xdebug', { | ||||||||||
| cause: error, | ||||||||||
| }); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I removed the explicit exit here so the callers can decide whether to exit or not. In automated tests, this case will no longer cause the test runner process (or worker) to exit, and in regular operation There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm happy as long as we're protected from this scenario:
I've struggled to get Xdebug to work too many times to let an Xdebug-related failure through.
Oh, it exited the test runner process? Should we run that in a sub-process instead? |
||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // We do not know the system temp dir, | ||||||||||
| // but we can try to infer from the location of the current temp dir. | ||||||||||
| const tempDirRoot = path.dirname(nativeDirPath); | ||||||||||
| const tempDirRoot = path.dirname(nativeDir.path); | ||||||||||
|
|
||||||||||
| const twoDaysInMillis = 2 * 24 * 60 * 60 * 1000; | ||||||||||
| const tempDirStaleAgeInMillis = twoDaysInMillis; | ||||||||||
|
|
@@ -721,7 +754,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
|
|
||||||||||
| // NOTE: We do not add mount declarations for /internal here | ||||||||||
| // because it will be mounted as part of php-wasm init. | ||||||||||
| const nativeInternalDirPath = path.join(nativeDirPath, 'internal'); | ||||||||||
| const nativeInternalDirPath = path.join(nativeDir.path, 'internal'); | ||||||||||
| mkdirSync(nativeInternalDirPath); | ||||||||||
|
|
||||||||||
| const userProvidableNativeSubdirs = [ | ||||||||||
|
|
@@ -746,7 +779,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
| // The user hasn't requested mounting a different native dir for this path, | ||||||||||
| // so let's create a mount from within our native temp dir. | ||||||||||
| const nativeSubdirPath = path.join( | ||||||||||
| nativeDirPath, | ||||||||||
| nativeDir.path, | ||||||||||
| subdirName | ||||||||||
| ); | ||||||||||
| mkdirSync(nativeSubdirPath); | ||||||||||
|
|
@@ -799,26 +832,48 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Remember whether we are already disposing so we can avoid: | ||||||||||
| // - we can avoid multiple, conflicting dispose attempts | ||||||||||
|
Comment on lines
+835
to
+836
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| // - logging that a worker exited while the CLI itself is exiting | ||||||||||
| let disposing = false; | ||||||||||
| const disposeCLI = async function disposeCLI() { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is now also used to clean up after |
||||||||||
| if (disposing) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| disposing = true; | ||||||||||
| await Promise.all( | ||||||||||
| playgroundsToCleanUp.map(async ({ playground, worker }) => { | ||||||||||
| await playground.dispose(); | ||||||||||
| await worker.terminate(); | ||||||||||
| }) | ||||||||||
| ); | ||||||||||
| if (server) { | ||||||||||
| await new Promise((resolve) => server.close(resolve)); | ||||||||||
| } | ||||||||||
| await nativeDir.cleanup(); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| // Kick off worker threads now to save time later. | ||||||||||
| // There is no need to wait for other async processes to complete. | ||||||||||
| const promisedWorkers = spawnWorkerThreads( | ||||||||||
| totalWorkerCount, | ||||||||||
| handler.getWorkerType(), | ||||||||||
| ({ exitCode, isMain, workerIndex }) => { | ||||||||||
| if (exitCode === 0) { | ||||||||||
| ({ exitCode, workerIndex }) => { | ||||||||||
| // We are already disposing, so worker exit is expected | ||||||||||
| // and does not need to be logged. | ||||||||||
| if (disposing) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (exitCode !== 0) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| logger.error( | ||||||||||
| `Worker ${workerIndex} exited with code ${exitCode}\n` | ||||||||||
| ); | ||||||||||
| // If the primary worker crashes, exit the entire process. | ||||||||||
| if (!isMain) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
| if (!args.exitOnPrimaryWorkerCrash) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
| process.exit(1); | ||||||||||
| // @TODO: Should we respawn the worker if it exited with an error and the CLI is not shutting down? | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I removed the explicit exit for primary worker because I believe that runCLI()'s promise will be rejected if the primary worker fails during boot. And I do not believe the primary worker is special any longer. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the user requested 5 workers then yes, let's keep that amount of workers around. Otherwise we may run out of workers eventually if one crashes every 5 minutes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I love not having a special primary worker btw There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (if you're curious about that Reddit link, it's hilarious)
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL @adamziel 😂 that is amazing, and I'm still laughing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm looking into the bug you mentioned. I think it's the same we talked about earlier today where trying to run a non-existing PHP file leads to this issue. |
||||||||||
| } | ||||||||||
| ); | ||||||||||
|
|
||||||||||
|
|
@@ -869,10 +924,12 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
| if (args.command === 'build-snapshot') { | ||||||||||
| await zipSite(playground, args.outfile as string); | ||||||||||
| logger.log(`WordPress exported to ${args.outfile}`); | ||||||||||
| process.exit(0); | ||||||||||
| await disposeCLI(); | ||||||||||
| return; | ||||||||||
| } else if (args.command === 'run-blueprint') { | ||||||||||
| logger.log(`Blueprint executed`); | ||||||||||
| process.exit(0); | ||||||||||
| await disposeCLI(); | ||||||||||
| return; | ||||||||||
|
Comment on lines
+927
to
+932
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here we remove explicit exits from |
||||||||||
| } | ||||||||||
|
|
||||||||||
| if ( | ||||||||||
|
|
@@ -927,17 +984,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> { | |||||||||
| playground, | ||||||||||
| server, | ||||||||||
| serverUrl, | ||||||||||
| [Symbol.asyncDispose]: async function disposeCLI() { | ||||||||||
| await Promise.all( | ||||||||||
| playgroundsToCleanUp.map( | ||||||||||
| async ({ playground, worker }) => { | ||||||||||
| await playground.dispose(); | ||||||||||
| await worker.terminate(); | ||||||||||
| } | ||||||||||
| ) | ||||||||||
| ); | ||||||||||
| await new Promise((resolve) => server.close(resolve)); | ||||||||||
| }, | ||||||||||
| [Symbol.asyncDispose]: disposeCLI, | ||||||||||
| workerThreadCount: totalWorkerCount, | ||||||||||
| }; | ||||||||||
| } catch (error) { | ||||||||||
|
|
@@ -993,19 +1040,14 @@ export type SpawnedWorker = { | |||||||||
| async function spawnWorkerThreads( | ||||||||||
| count: number, | ||||||||||
| workerType: WorkerType, | ||||||||||
| onWorkerExit: (options: { | ||||||||||
| exitCode: number; | ||||||||||
| isMain: boolean; | ||||||||||
| workerIndex: number; | ||||||||||
| }) => void | ||||||||||
| onWorkerExit: (options: { exitCode: number; workerIndex: number }) => void | ||||||||||
| ): Promise<SpawnedWorker[]> { | ||||||||||
| const promises = []; | ||||||||||
| for (let i = 0; i < count; i++) { | ||||||||||
| const worker = await spawnWorkerThread(workerType); | ||||||||||
| const onExit: (code: number) => void = (code: number) => { | ||||||||||
| onWorkerExit({ | ||||||||||
| exitCode: code, | ||||||||||
| isMain: i === 0, | ||||||||||
| workerIndex: i, | ||||||||||
| }); | ||||||||||
| }; | ||||||||||
|
|
||||||||||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have explicit result modes for every outcome?
undefinedmeans "success, no server", Server instance means there's a server running, an exception means an error. That sounds fine for the initial launch. Do we have any way of reporting errors further down the road other than the logger? If not, that may be a good exploration after this PR.