diff --git a/scripts/dev-runner.cjs b/scripts/dev-runner.cjs index e73d44b..10a991a 100644 --- a/scripts/dev-runner.cjs +++ b/scripts/dev-runner.cjs @@ -6,19 +6,97 @@ const chokidar = require('chokidar'); const root = path.resolve(__dirname, '..'); const electron = require('electron'); 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; +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) { if (!Number.isInteger(pid) || pid <= 0) return false; try { @@ -56,71 +134,115 @@ function releaseLock() { } catch {} } -function startApp() { - const startedChild = spawn(electron, ['.', '--dev'], { +function startElectron() { + return spawn(electron, ['.', '--dev'], { cwd: root, stdio: 'inherit', windowsHide: false }); - child = startedChild; - startedChild.once('exit', (code, signal) => { - if (child !== startedChild) return; - child = null; - if (!stopping && code !== 0 && signal !== 'SIGTERM') process.exitCode = code || 1; +} + +function stopProcessTree(child) { + if (process.platform === 'win32') { + 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) { - if (!child || !child.pid) { - done(); +function runDevRunner() { + if (!acquireLock()) { + process.stderr.write('A Multi-Hoster hot-dev runner is already active.\n'); + process.exit(0); return; } - const pid = child.pid; - child = null; - if (process.platform === 'win32') { - const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }); - killer.once('close', done); - return; + + const controller = createRestartController({ + startChild: startElectron, + stopChild: stopProcessTree, + onUnexpectedExit(code) { + 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() { - if (stopping) return; - stopApp(startApp); -} +module.exports = { createRestartController, createWatchedPaths }; -function scheduleRestart() { - 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(); +if (require.main === module) runDevRunner(); diff --git a/tests/dev-runner.test.js b/tests/dev-runner.test.js index d92f660..2b888cf 100644 --- a/tests/dev-runner.test.js +++ b/tests/dev-runner.test.js @@ -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') + ]); });