Repository navigation
Description
Pre-flight checklist
- I have read the contribution documentation for this project.
- I agree to follow the code of conduct that this project uses.
- I have searched the issue tracker for a bug that matches the one I want to file, without success.
Forge version
8.0.1 (@electron-forge/cli, core, plugin-vite, and multi-logger).
Electron version
44.7.0 in the affected application. The isolated reproduction below does not require an Electron binary.
Operating system
macOS 26.6, arm64; Node.js 26.11.1; pnpm 12.10.1.
Last known working Forge version
Observed after upgrading to Forge 8; the exact prior working version has not been bisected.
Expected behavior
Pressing q in the Forge development TUI should terminate the app as well as Forge.
Actual behavior
With the Vite plugin enabled, q can exit Forge successfully while leaving Electron running. Vite's first SIGINT listener calls process.exit(0) before Forge's later listener can forward the signal to the app.
Steps to reproduce
This isolates the conflict using the published packages. It calls the same runtime quit handler as the TUI's q action, uses a Node process as the app stand-in, and registers the equivalent of Forge core's app-forwarding listener. It deliberately avoids requiring an interactive terminal or real Electron window. The supervisor checks the child after Forge exits and cleans up only its own stand-in process.
In an empty directory, with Node.js >= 22.13.0:
printf '{"private":true,"type":"module"}\n' > package.json
pnpm add --ignore-scripts @electron-forge/plugin-vite@8.0.1 @electron-forge/multi-logger@8.0.1 vite@8.0.0Save the following as repro.mjs, then run node repro.mjs:
import { spawn } from 'node:child_process';
import { once } from 'node:events';
// Run Forge's quit path in a separate process so this supervisor can check
// whether its app child survives and clean up only that child afterward.
const worker = spawn(process.execPath, ['--input-type=module', '-e', `
import fs from 'node:fs';
import { spawn } from 'node:child_process';
import VitePlugin from '@electron-forge/plugin-vite';
import Logger from '@electron-forge/multi-logger';
// Vite registers its SIGINT listener before Forge registers forwarding.
new VitePlugin({ build: [], renderer: [] }).init(process.cwd());
const app = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'],
{ stdio: 'ignore' });
fs.writeSync(1, 'APP_PID=' + app.pid + '\\n');
process.on('SIGINT', signal => {
fs.writeSync(1, 'SIGNAL_FORWARDED\\n');
app.kill(signal); // Same forwarding as Forge core's start handler.
});
new Logger({ forceMode: 'plain' }).quit(); // The TUI's q action.
`], { stdio: ['ignore', 'pipe', 'inherit'] });
let output = '';
worker.stdout.on('data', chunk => output += chunk);
const [exitCode] = await once(worker, 'close');
const match = output.match(/APP_PID=(\d+)/);
if (!match) throw new Error('Worker failed before starting the app');
const appPid = Number(match[1]);
const isAlive = () => {
try { process.kill(appPid, 0); return true; }
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
};
// Give an app that received SIGINT time to exit before checking liveness.
const deadline = Date.now() + 2000;
while (isAlive() && Date.now() < deadline)
await new Promise(resolve => setTimeout(resolve, 20));
console.log({ exitCode, signalForwarded: output.includes('SIGNAL_FORWARDED'),
appStillAlive: isAlive() });
if (isAlive()) process.kill(appPid, 'SIGKILL'); // Clean up our own stand-in.Observed with unmodified published 8.0.1 packages:
{ exitCode: 0, signalForwarded: false, appStillAlive: true }
Expected:
{ exitCode: 0, signalForwarded: true, appStillAlive: false }
Additional information
The relevant ordering in the 8.0.1 source is:
- Vite registers a synchronous exiting SIGINT listener during plugin initialization.
- Forge registers app signal forwarding later.
- The logger's quit handler emits a JavaScript SIGINT event, rather than sending an OS signal to the process group. Vite exits the process before the second listener can run.
Proposed fix
Defer Vite's forced exit until the other signal listeners have run:
process.on('SIGINT' as NodeJS.Signals, (_signal) => {
- this.exitHandler({ exit: true });
+ setImmediate(() => this.exitHandler({ exit: true }));
});This preserves the existing forced-exit behavior and the existing exit hook that cleans up Vite watchers and servers, while giving Forge's app-forwarding handler a chance to run.
The same isolated reproduction, using our patched 8.0.1 Vite plugin, produces the expected output above. A separate temporary integration check using Forge's actual start API and shared logger also passed three quit runs, an OS SIGINT run, and quitting before an app was launched. That check uses a Node stand-in as well; it does not claim a real Electron UI test. The existing Vite development/production isolation checks also passed.
— sent via Glean Tau
Activity
rf- commentedon Oct 17, 2026
This was fixed by #4437. Your repro script still fails, but it's because it inlines the listener setup that was changed in that PR—if you edit the repro script to use
process.prependListenerit passes.