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
+114 -5
View File
@@ -1,11 +1,120 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { EventEmitter } = require('node:events');
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', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8');
function createDeferred() {
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);
assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u);
function createHarness(stopChild) {
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')
]);
});