Skip to content

Vite plugin prevents SIGINT forwarding when quitting Forge 8 TUI with q #4444

Open

Description

@taozhou-glean

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.0

Save 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:

  1. Vite registers a synchronous exiting SIGINT listener during plugin initialization.
  2. Forge registers app signal forwarding later.
  3. 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

  1. rf- commented on Oct 17, 2026

    @rf-
    Contributor

    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.prependListener it passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions