fix(dev): serialize Electron hot reload restarts
Replace callback-based restarts with a coalescing state machine that keeps the active child tracked through tree termination and prevents overlapping kill/start cycles. Block replacement starts after kill failures or shutdown signals, preserve stale-exit protection and source watch coverage, and add deterministic regression tests for restart races.
This commit is contained in:
+188
-66
@@ -6,19 +6,97 @@ const chokidar = require('chokidar');
|
|||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
const electron = require('electron');
|
const electron = require('electron');
|
||||||
const lockPath = path.join(root, '.dev-runner.lock');
|
const lockPath = path.join(root, '.dev-runner.lock');
|
||||||
const watched = [
|
|
||||||
'main.js',
|
|
||||||
'preload.js',
|
|
||||||
'preload-drop-target.js',
|
|
||||||
path.join(root, 'lib'),
|
|
||||||
path.join(root, 'renderer')
|
|
||||||
].map(target => path.isAbsolute(target) ? target : path.join(root, target));
|
|
||||||
|
|
||||||
let child = null;
|
|
||||||
let restartTimer = null;
|
|
||||||
let stopping = false;
|
|
||||||
let lockHandle = null;
|
let lockHandle = null;
|
||||||
|
|
||||||
|
function createWatchedPaths(projectRoot) {
|
||||||
|
return [
|
||||||
|
'main.js',
|
||||||
|
'preload.js',
|
||||||
|
'preload-drop-target.js',
|
||||||
|
'lib',
|
||||||
|
'renderer'
|
||||||
|
].map(target => path.join(projectRoot, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRestartController({ startChild, stopChild, onUnexpectedExit = () => {} }) {
|
||||||
|
let child = null;
|
||||||
|
let terminatingChild = null;
|
||||||
|
let restartScheduled = false;
|
||||||
|
let restartWaiters = [];
|
||||||
|
let cycleDone = Promise.resolve();
|
||||||
|
let stopping = false;
|
||||||
|
let shutdownPromise = null;
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopping || child) return child;
|
||||||
|
const startedChild = startChild();
|
||||||
|
child = startedChild;
|
||||||
|
startedChild.once('exit', (code, signal) => {
|
||||||
|
if (child !== startedChild) return;
|
||||||
|
const expectedExit = stopping || terminatingChild === startedChild;
|
||||||
|
child = null;
|
||||||
|
if (!expectedExit && code !== 0 && signal !== 'SIGTERM') onUnexpectedExit(code, signal);
|
||||||
|
});
|
||||||
|
return startedChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopCurrentChild() {
|
||||||
|
const target = child;
|
||||||
|
if (!target || !target.pid) return;
|
||||||
|
terminatingChild = target;
|
||||||
|
try {
|
||||||
|
await stopChild(target);
|
||||||
|
} catch (error) {
|
||||||
|
if (child === target) throw error;
|
||||||
|
} finally {
|
||||||
|
if (terminatingChild === target) terminatingChild = null;
|
||||||
|
}
|
||||||
|
if (child === target) child = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRestartCycle() {
|
||||||
|
const waiters = restartWaiters;
|
||||||
|
restartWaiters = [];
|
||||||
|
try {
|
||||||
|
await stopCurrentChild();
|
||||||
|
waiters.push(...restartWaiters);
|
||||||
|
restartWaiters = [];
|
||||||
|
if (!stopping) start();
|
||||||
|
for (const waiter of waiters) waiter.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
waiters.push(...restartWaiters);
|
||||||
|
restartWaiters = [];
|
||||||
|
for (const waiter of waiters) waiter.reject(error);
|
||||||
|
} finally {
|
||||||
|
restartScheduled = false;
|
||||||
|
if (restartWaiters.length > 0 && !stopping) beginRestartCycle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginRestartCycle() {
|
||||||
|
restartScheduled = true;
|
||||||
|
cycleDone = runRestartCycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
if (stopping) return shutdownPromise || Promise.resolve();
|
||||||
|
const requested = new Promise((resolve, reject) => {
|
||||||
|
restartWaiters.push({ resolve, reject });
|
||||||
|
});
|
||||||
|
if (!restartScheduled) beginRestartCycle();
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
if (shutdownPromise) return shutdownPromise;
|
||||||
|
stopping = true;
|
||||||
|
shutdownPromise = cycleDone.then(stopCurrentChild);
|
||||||
|
return shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restart, shutdown, start };
|
||||||
|
}
|
||||||
|
|
||||||
function processExists(pid) {
|
function processExists(pid) {
|
||||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
try {
|
try {
|
||||||
@@ -56,71 +134,115 @@ function releaseLock() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startApp() {
|
function startElectron() {
|
||||||
const startedChild = spawn(electron, ['.', '--dev'], {
|
return spawn(electron, ['.', '--dev'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
windowsHide: false
|
windowsHide: false
|
||||||
});
|
});
|
||||||
child = startedChild;
|
}
|
||||||
startedChild.once('exit', (code, signal) => {
|
|
||||||
if (child !== startedChild) return;
|
function stopProcessTree(child) {
|
||||||
child = null;
|
if (process.platform === 'win32') {
|
||||||
if (!stopping && code !== 0 && signal !== 'SIGTERM') process.exitCode = code || 1;
|
return new Promise((resolve, reject) => {
|
||||||
|
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
killer.once('error', reject);
|
||||||
|
killer.once('close', (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`taskkill failed for Electron PID ${child.pid}: ${code ?? signal ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleExit = () => resolve();
|
||||||
|
child.once('exit', handleExit);
|
||||||
|
try {
|
||||||
|
process.kill(child.pid, 'SIGTERM');
|
||||||
|
} catch (error) {
|
||||||
|
child.removeListener('exit', handleExit);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopApp(done) {
|
function runDevRunner() {
|
||||||
if (!child || !child.pid) {
|
if (!acquireLock()) {
|
||||||
done();
|
process.stderr.write('A Multi-Hoster hot-dev runner is already active.\n');
|
||||||
|
process.exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pid = child.pid;
|
|
||||||
child = null;
|
const controller = createRestartController({
|
||||||
if (process.platform === 'win32') {
|
startChild: startElectron,
|
||||||
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
|
stopChild: stopProcessTree,
|
||||||
killer.once('close', done);
|
onUnexpectedExit(code) {
|
||||||
return;
|
process.exitCode = code || 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const watcher = chokidar.watch(createWatchedPaths(root), {
|
||||||
|
ignoreInitial: true,
|
||||||
|
usePolling: true,
|
||||||
|
interval: 100,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 }
|
||||||
|
});
|
||||||
|
let restartTimer = null;
|
||||||
|
let shutdownPromise = null;
|
||||||
|
|
||||||
|
function reportRestartFailure(error) {
|
||||||
|
process.stderr.write(`[hotdev] restart failed: ${error.message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
process.kill(pid, 'SIGTERM');
|
|
||||||
done();
|
function scheduleRestart() {
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = null;
|
||||||
|
controller.restart().catch(reportRestartFailure);
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.on('all', (_event, file) => {
|
||||||
|
process.stdout.write(`[hotdev] renderer change detected: ${file}\n`);
|
||||||
|
scheduleRestart();
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
if (shutdownPromise) return shutdownPromise;
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
const appShutdown = controller.shutdown();
|
||||||
|
shutdownPromise = Promise.allSettled([
|
||||||
|
Promise.resolve().then(() => watcher.close()),
|
||||||
|
appShutdown
|
||||||
|
]).then(results => {
|
||||||
|
const failure = results.find(result => result.status === 'rejected');
|
||||||
|
releaseLock();
|
||||||
|
if (failure) {
|
||||||
|
process.stderr.write(`[hotdev] shutdown failed: ${failure.reason.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
return shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
process.once('exit', () => {
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
controller.shutdown().catch(() => {});
|
||||||
|
releaseLock();
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartApp() {
|
module.exports = { createRestartController, createWatchedPaths };
|
||||||
if (stopping) return;
|
|
||||||
stopApp(startApp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRestart() {
|
if (require.main === module) runDevRunner();
|
||||||
clearTimeout(restartTimer);
|
|
||||||
restartTimer = setTimeout(restartApp, 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!acquireLock()) {
|
|
||||||
process.stderr.write('A Multi-Hoster hot-dev runner is already active.\n');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const watcher = chokidar.watch(watched, {
|
|
||||||
ignoreInitial: true,
|
|
||||||
usePolling: true,
|
|
||||||
interval: 100,
|
|
||||||
awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 }
|
|
||||||
});
|
|
||||||
watcher.on('all', (_event, file) => {
|
|
||||||
process.stdout.write(`[hotdev] renderer change detected: ${file}\n`);
|
|
||||||
scheduleRestart();
|
|
||||||
});
|
|
||||||
|
|
||||||
function shutdown() {
|
|
||||||
if (stopping) return;
|
|
||||||
stopping = true;
|
|
||||||
clearTimeout(restartTimer);
|
|
||||||
watcher.close().finally(() => stopApp(() => { releaseLock(); process.exit(0); }));
|
|
||||||
}
|
|
||||||
|
|
||||||
process.once('SIGINT', shutdown);
|
|
||||||
process.once('SIGTERM', shutdown);
|
|
||||||
process.once('exit', () => { stopping = true; releaseLock(); });
|
|
||||||
|
|
||||||
startApp();
|
|
||||||
|
|||||||
+114
-5
@@ -1,11 +1,120 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const { EventEmitter } = require('node:events');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const { createRestartController, createWatchedPaths } = require('../scripts/dev-runner.cjs');
|
||||||
|
|
||||||
test('dev runner cannot let an old child exit clear the current Electron process', () => {
|
function createDeferred() {
|
||||||
const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8');
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
assert.match(source, /const startedChild = spawn\(electron/u);
|
function createHarness(stopChild) {
|
||||||
assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u);
|
const started = [];
|
||||||
|
const stopped = [];
|
||||||
|
const unexpectedExits = [];
|
||||||
|
let nextPid = 1;
|
||||||
|
const controller = createRestartController({
|
||||||
|
startChild() {
|
||||||
|
const child = new EventEmitter();
|
||||||
|
child.pid = nextPid;
|
||||||
|
nextPid += 1;
|
||||||
|
started.push(child);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
stopChild(child) {
|
||||||
|
stopped.push(child);
|
||||||
|
return stopChild(child);
|
||||||
|
},
|
||||||
|
onUnexpectedExit(code, signal) {
|
||||||
|
unexpectedExits.push({ code, signal });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { controller, started, stopped, unexpectedExits };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('three changes during an open kill produce exactly one replacement Electron tree', async () => {
|
||||||
|
const kill = createDeferred();
|
||||||
|
const harness = createHarness(() => kill.promise);
|
||||||
|
const original = harness.controller.start();
|
||||||
|
const firstRestart = harness.controller.restart();
|
||||||
|
|
||||||
|
const pendingChanges = [
|
||||||
|
harness.controller.restart(),
|
||||||
|
harness.controller.restart(),
|
||||||
|
harness.controller.restart()
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(harness.stopped.length, 1);
|
||||||
|
assert.strictEqual(harness.stopped[0], original);
|
||||||
|
assert.strictEqual(harness.controller.start(), original);
|
||||||
|
assert.equal(harness.started.length, 1);
|
||||||
|
|
||||||
|
kill.resolve();
|
||||||
|
await Promise.all([firstRestart, ...pendingChanges]);
|
||||||
|
|
||||||
|
assert.equal(harness.stopped.length, 1);
|
||||||
|
assert.equal(harness.started.length, 2);
|
||||||
|
assert.notStrictEqual(harness.started[1], original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed kill cannot start Electron over the still-running tree', async () => {
|
||||||
|
const harness = createHarness(async () => {
|
||||||
|
throw new Error('taskkill failed');
|
||||||
|
});
|
||||||
|
const original = harness.controller.start();
|
||||||
|
|
||||||
|
await assert.rejects(harness.controller.restart(), /taskkill failed/u);
|
||||||
|
|
||||||
|
assert.equal(harness.stopped.length, 1);
|
||||||
|
assert.equal(harness.started.length, 1);
|
||||||
|
assert.strictEqual(harness.controller.start(), original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown during an open restart kill prevents its completion from starting Electron', async () => {
|
||||||
|
const kill = createDeferred();
|
||||||
|
const harness = createHarness(() => kill.promise);
|
||||||
|
harness.controller.start();
|
||||||
|
const restart = harness.controller.restart();
|
||||||
|
const shutdown = harness.controller.shutdown();
|
||||||
|
|
||||||
|
assert.equal(harness.stopped.length, 1);
|
||||||
|
assert.equal(harness.started.length, 1);
|
||||||
|
|
||||||
|
kill.resolve();
|
||||||
|
await Promise.all([restart, shutdown]);
|
||||||
|
|
||||||
|
assert.equal(harness.stopped.length, 1);
|
||||||
|
assert.equal(harness.started.length, 1);
|
||||||
|
assert.equal(harness.controller.start(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old child exit cannot clear the replacement Electron child', async () => {
|
||||||
|
const harness = createHarness(async () => {});
|
||||||
|
const original = harness.controller.start();
|
||||||
|
|
||||||
|
await harness.controller.restart();
|
||||||
|
const replacement = harness.started[1];
|
||||||
|
original.emit('exit', 0, 'SIGTERM');
|
||||||
|
|
||||||
|
assert.equal(harness.started.length, 2);
|
||||||
|
assert.strictEqual(harness.controller.start(), replacement);
|
||||||
|
assert.deepEqual(harness.unexpectedExits, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watch paths still cover main, preloads, lib and renderer', () => {
|
||||||
|
const projectRoot = path.resolve('C:\\project');
|
||||||
|
|
||||||
|
assert.deepEqual([...createWatchedPaths(projectRoot)], [
|
||||||
|
path.join(projectRoot, 'main.js'),
|
||||||
|
path.join(projectRoot, 'preload.js'),
|
||||||
|
path.join(projectRoot, 'preload-drop-target.js'),
|
||||||
|
path.join(projectRoot, 'lib'),
|
||||||
|
path.join(projectRoot, 'renderer')
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user