Skip to content

Commit 53551c7

Browse files
committed
fix(bundler): tear the bundler down before starting another one
Stopping a bundler only sent SIGINT and returned, so a restart could spawn a replacement while the old watcher was still alive. The stale child's exit then evicted the replacement's map entry, and a compilation finishing on it still reached the prepare controller. Await the child's exit (escalating to SIGKILL), detach its output and IPC handlers, and key every eviction on process identity. The prepare controller now keeps its compilation handler per platform, so stopping one platform no longer leaves the other's listener attached.
1 parent 6d8cc6e commit 53551c7

3 files changed

Lines changed: 278 additions & 26 deletions

File tree

lib/controllers/prepare-controller.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import { resolvePackageJSONPath } from "@rigor789/resolve-package-path";
4444

4545
interface IPlatformWatcherData {
4646
hasWebpackCompilerProcess: boolean;
47+
/** Kept per platform: one process watches several of them at a time. */
48+
bundlerCompilerHandler: (data: any) => void;
4749
nativeFilesWatcher: FSWatcher;
4850
prepareArguments: {
4951
prepareData: IPrepareData;
@@ -59,7 +61,6 @@ export class PrepareController
5961
private watchersData: IDictionary<IDictionary<IPlatformWatcherData>> = {};
6062
private isInitialPrepareReady = false;
6163
private persistedData: IFilesChangeEventData[] = [];
62-
private webpackCompilerHandler: any = null;
6364
private pausedFileWatch: boolean = false;
6465

6566
constructor(
@@ -125,14 +126,16 @@ export class PrepareController
125126
this.watchersData[projectDir][platformLowerCase] &&
126127
this.watchersData[projectDir][platformLowerCase].hasWebpackCompilerProcess
127128
) {
129+
const watcherData = this.watchersData[projectDir][platformLowerCase];
128130
await this.$bundlerCompilerService.stopBundlerCompiler(platformLowerCase);
129-
this.$bundlerCompilerService.removeListener(
130-
BUNDLER_COMPILATION_COMPLETE,
131-
this.webpackCompilerHandler,
132-
);
133-
this.watchersData[projectDir][
134-
platformLowerCase
135-
].hasWebpackCompilerProcess = false;
131+
if (watcherData.bundlerCompilerHandler) {
132+
this.$bundlerCompilerService.removeListener(
133+
BUNDLER_COMPILATION_COMPLETE,
134+
watcherData.bundlerCompilerHandler,
135+
);
136+
watcherData.bundlerCompilerHandler = null;
137+
}
138+
watcherData.hasWebpackCompilerProcess = false;
136139
}
137140
}
138141

@@ -237,6 +240,7 @@ export class PrepareController
237240
] = {
238241
nativeFilesWatcher: null,
239242
hasWebpackCompilerProcess: false,
243+
bundlerCompilerHandler: null,
240244
prepareArguments: {
241245
platformData,
242246
projectData,
@@ -303,15 +307,17 @@ export class PrepareController
303307
}
304308
};
305309

306-
this.webpackCompilerHandler = handler.bind(this);
310+
const watcherData =
311+
this.watchersData[projectData.projectDir][
312+
platformData.platformNameLowerCase
313+
];
314+
watcherData.bundlerCompilerHandler = handler.bind(this);
307315
this.$bundlerCompilerService.on(
308316
BUNDLER_COMPILATION_COMPLETE,
309-
this.webpackCompilerHandler,
317+
watcherData.bundlerCompilerHandler,
310318
);
311319

312-
this.watchersData[projectData.projectDir][
313-
platformData.platformNameLowerCase
314-
].hasWebpackCompilerProcess = true;
320+
watcherData.hasWebpackCompilerProcess = true;
315321
await this.$bundlerCompilerService.compileWithWatch(
316322
platformData,
317323
projectData,

lib/services/bundler/bundler-compiler-service.ts

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ interface IBundlerCompilation {
5656
/* for specific bundling debugging separate from logger */
5757
const debugLog = false;
5858

59+
/** Grace period a bundler child gets to honour SIGINT before it is killed. */
60+
const BUNDLER_STOP_TIMEOUT_MS = 5000;
61+
5962
export class BundlerCompilerService
6063
extends EventEmitter
6164
implements IBundlerCompilerService
@@ -382,7 +385,10 @@ export class BundlerCompilerService
382385
this.$logger.trace(
383386
`Unable to start ${projectData.bundler} process in watch mode. Error is: ${err}`,
384387
);
385-
delete this.bundlerProcesses[platformData.platformNameLowerCase];
388+
this.forgetBundlerProcess(
389+
platformData.platformNameLowerCase,
390+
childProcess,
391+
);
386392
reject(err);
387393
});
388394

@@ -399,7 +405,10 @@ export class BundlerCompilerService
399405
`Executing ${projectData.bundler} failed with exit code ${exitCode}.`,
400406
);
401407
error.code = exitCode;
402-
delete this.bundlerProcesses[platformData.platformNameLowerCase];
408+
this.forgetBundlerProcess(
409+
platformData.platformNameLowerCase,
410+
childProcess,
411+
);
403412
reject(error);
404413
});
405414
} catch (err) {
@@ -430,7 +439,10 @@ export class BundlerCompilerService
430439
this.$logger.trace(
431440
`Unable to start ${projectData.bundler} process in non-watch mode. Error is: ${err}`,
432441
);
433-
delete this.bundlerProcesses[platformData.platformNameLowerCase];
442+
this.forgetBundlerProcess(
443+
platformData.platformNameLowerCase,
444+
childProcess,
445+
);
434446
reject(err);
435447
});
436448

@@ -441,7 +453,10 @@ export class BundlerCompilerService
441453
childProcess.pid.toString(),
442454
);
443455

444-
delete this.bundlerProcesses[platformData.platformNameLowerCase];
456+
this.forgetBundlerProcess(
457+
platformData.platformNameLowerCase,
458+
childProcess,
459+
);
445460
const exitCode = typeof arg === "number" ? arg : arg && arg.code;
446461
if (exitCode === 0) {
447462
// Non-watch Vite builds spawn the child with stdio:"inherit"
@@ -748,7 +763,9 @@ export class BundlerCompilerService
748763
await this.$cleanupService.addKillProcess(childProcess.pid.toString());
749764

750765
childProcess.once("exit", (code: number) => {
751-
delete this.viteServeProcesses[key];
766+
if (this.viteServeProcesses[key] === childProcess) {
767+
delete this.viteServeProcesses[key];
768+
}
752769
if (code) {
753770
this.$logger.warn(
754771
`Vite dev server for ${key} exited with code ${code}.`,
@@ -1000,22 +1017,84 @@ export class BundlerCompilerService
10001017
this.$logger.trace(
10011018
`Stopping ${this.getBundler()} watch for platform ${platform}.`,
10021019
);
1020+
10031021
const bundlerProcess = this.bundlerProcesses[platform];
1004-
await this.$cleanupService.removeKillProcess(bundlerProcess.pid.toString());
10051022
if (bundlerProcess) {
1006-
bundlerProcess.kill("SIGINT");
1007-
delete this.bundlerProcesses[platform];
1023+
// A compilation already in flight can still reach us between the
1024+
// kill and the exit; nothing downstream may act on output from a
1025+
// watcher the caller has torn down.
1026+
bundlerProcess.removeAllListeners("message");
1027+
bundlerProcess.stdout?.removeAllListeners("data");
1028+
bundlerProcess.stderr?.removeAllListeners("data");
1029+
1030+
await this.terminate(bundlerProcess);
1031+
this.forgetBundlerProcess(platform, bundlerProcess);
10081032
}
10091033

10101034
// Tear down the Vite dev server we manage alongside the build watcher.
10111035
const viteServeProcess = this.viteServeProcesses[platform];
10121036
if (viteServeProcess) {
1013-
await this.$cleanupService.removeKillProcess(
1014-
viteServeProcess.pid.toString(),
1015-
);
1016-
viteServeProcess.kill("SIGINT");
1017-
delete this.viteServeProcesses[platform];
1037+
await this.terminate(viteServeProcess);
1038+
if (this.viteServeProcesses[platform] === viteServeProcess) {
1039+
delete this.viteServeProcesses[platform];
1040+
}
1041+
}
1042+
}
1043+
1044+
/**
1045+
* Drops a platform's entry only while it still points at `childProcess`, so
1046+
* an exit arriving after a restart cannot evict the replacement watcher.
1047+
*/
1048+
private forgetBundlerProcess(
1049+
platform: string,
1050+
childProcess: child_process.ChildProcess,
1051+
): void {
1052+
if (this.bundlerProcesses[platform] === childProcess) {
1053+
delete this.bundlerProcesses[platform];
1054+
}
1055+
}
1056+
1057+
/**
1058+
* Resolves once the child is gone, so a caller that restarts the bundler
1059+
* cannot spawn a replacement while the old one still holds the watch.
1060+
*/
1061+
private async terminate(
1062+
childProcess: child_process.ChildProcess,
1063+
timeoutMs: number = BUNDLER_STOP_TIMEOUT_MS,
1064+
): Promise<void> {
1065+
await this.$cleanupService.removeKillProcess(childProcess.pid.toString());
1066+
1067+
childProcess.kill("SIGINT");
1068+
if (await this.waitForExit(childProcess, timeoutMs)) {
1069+
return;
10181070
}
1071+
1072+
this.$logger.trace(
1073+
`Process ${childProcess.pid} did not exit on SIGINT within ${timeoutMs}ms; sending SIGKILL.`,
1074+
);
1075+
childProcess.kill("SIGKILL");
1076+
await this.waitForExit(childProcess, timeoutMs);
1077+
}
1078+
1079+
private waitForExit(
1080+
childProcess: child_process.ChildProcess,
1081+
timeoutMs: number,
1082+
): Promise<boolean> {
1083+
return new Promise<boolean>((resolve) => {
1084+
const settle = (exited: boolean) => {
1085+
clearTimeout(timer);
1086+
childProcess.removeListener("exit", onExit);
1087+
childProcess.removeListener("close", onExit);
1088+
resolve(exited);
1089+
};
1090+
const onExit = () => settle(true);
1091+
const timer = setTimeout(() => settle(false), timeoutMs);
1092+
// A pending timer must not be what keeps the CLI alive.
1093+
timer.unref?.();
1094+
1095+
childProcess.once("exit", onExit);
1096+
childProcess.once("close", onExit);
1097+
});
10191098
}
10201099

10211100
private handleHMRMessage(

0 commit comments

Comments
 (0)