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:
Sucukdeluxe
2026-08-13 20:55:24 +02:00
parent f9b57f4b0e
commit 2b56cd6373
2 changed files with 302 additions and 71 deletions
+188 -66
View File
@@ -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
View File
@@ -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')
]);
}); });