Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/api/cli/src/electron-forge-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ import packageJSON from '../package.json' with { type: 'json' };
};
onExit = (code: number) => {
removeListeners();
if (spawned.restarted) return;
// `child`, not `spawned`: the first child's `restarted` flag stays true
// forever, so using it would ignore every exit after the first restart.
if (child.restarted) return;
if (code !== 0) {
process.exit(code);
}
Expand Down
126 changes: 125 additions & 1 deletion packages/api/core/spec/fast/start.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { ChildProcess, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';

import { requestAppRestart } from '@electron-forge/core-utils/restart';
import { ElectronProcess } from '@electron-forge/shared-types';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import start from '../../src/api/start';
import locateElectronExecutable from '../../src/util/electron-executable.js';
import findConfig from '../../src/util/forge-config.js';
import { readMutatedPackageJson } from '../../src/util/read-package-json.js';
import resolveDir from '../../src/util/resolve-dir.js';
Expand Down Expand Up @@ -243,6 +246,127 @@ describe('start', () => {
).rejects.toThrowError("Please set your application's 'version' in");
});

describe('restarting', () => {
// A stand-in for a spawned Electron process that dies when killed.
const fakeChild = () => {
const child = new EventEmitter() as ElectronProcess & {
kill: ReturnType<typeof vi.fn>;
};
child.kill = vi.fn(() => {
child.emit('exit');
child.emit('close');
return true;
});
return child;
};

const spawnsInOrder = (...children: ReturnType<typeof fakeChild>[]) => {
const mock = vi.mocked(spawn);
for (const child of children) mock.mockReturnValueOnce(child);
return children;
};

beforeEach(() => {
// Silence the "Restarting Electron app" line.
vi.spyOn(console, 'info').mockImplementation(() => undefined);
});

it('kills the running app and hands back its replacement', async () => {
const [first, second] = spawnsInOrder(fakeChild(), fakeChild());

const spawned = await start({
dir: import.meta.dirname,
interactive: false,
});
expect(spawned).toBe(first);

const replaced = new Promise((resolve) =>
spawned.on('restarted', resolve),
);

expect(requestAppRestart()).toBe(true);
expect(first.restarted).toBe(true);
expect(first.kill).toHaveBeenCalledWith('SIGTERM');

// The event has to fire on the *exiting* child: that is what re-attaches
// the CLI's exit handling to the replacement.
await expect(replaced).resolves.toBe(second);
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2);
});

it('initializes `restarted` instead of leaving it undefined', async () => {
spawnsInOrder(fakeChild());

const spawned = await start({
dir: import.meta.dirname,
interactive: false,
});

expect(spawned.restarted).toBe(false);
});

it('declines to restart when no app is running', async () => {
// `spawn` is mocked with no return value, so nothing is ever running.
await start({ dir: import.meta.dirname, interactive: false });

expect(requestAppRestart()).toBe(false);
});

it('still restarts once more when asked mid-restart', async () => {
spawnsInOrder(fakeChild(), fakeChild(), fakeChild());

await start({ dir: import.meta.dirname, interactive: false });

expect(requestAppRestart()).toBe(true);
// A request landing mid-restart must not be dropped, or the app keeps
// running the code that was just replaced.
expect(requestAppRestart()).toBe(true);

await vi.waitFor(() => expect(vi.mocked(spawn)).toHaveBeenCalledTimes(3));
});

it('keeps the replacement app when the old one closes late', async () => {
const [first, second] = spawnsInOrder(fakeChild(), fakeChild());
first.kill = vi.fn(() => {
first.emit('exit');
return true;
});

const spawned = await start({
dir: import.meta.dirname,
interactive: false,
});
const replaced = new Promise((resolve) =>
spawned.on('restarted', resolve),
);
requestAppRestart();
await replaced;

// A late `close` from the old child must not clear the live replacement.
first.emit('close');

expect(requestAppRestart()).toBe(true);
expect(second.kill).toHaveBeenCalledWith('SIGTERM');
});

it('reports a failed relaunch rather than rejecting unobserved', async () => {
spawnsInOrder(fakeChild());
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);

await start({ dir: import.meta.dirname, interactive: false });

vi.mocked(locateElectronExecutable).mockRejectedValueOnce(
new Error('electron is gone'),
);
requestAppRestart();

// An unhandled rejection here would take down the whole Forge process.
await vi.waitFor(() => expect(consoleError).toHaveBeenCalled());
});
});

// TODO(erickzhao): improve test coverage
it.todo(
'allows plugin to override the start command with a custom spawn string',
Expand Down
92 changes: 77 additions & 15 deletions packages/api/core/src/api/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
getElectronVersion,
listrCompatibleRebuildHook,
} from '@electron-forge/core-utils';
import {
requestAppRestart,
setAppRestartHandler,
} from '@electron-forge/core-utils/restart';
import {
ElectronProcess,
ForgeArch,
Expand Down Expand Up @@ -265,6 +269,10 @@ export default autoTrace(
const spawned = await forgeSpawn();
// When the child app is closed we should stop listening for stdin
if (spawned) {
// `restarted` is non-optional on `ElectronProcess`, so don't leave it
// `undefined` until the first restart.
spawned.restarted = false;

if (interactive && process.stdin.isPaused()) {
process.stdin.resume();
}
Expand All @@ -278,9 +286,12 @@ export default autoTrace(
}
});

// On close, reset lastSpawned, it's dead
// On close, reset lastSpawned, it's dead. A restart may already have put
// a replacement there, so only clear our own child.
spawned.on('close', () => {
lastSpawned = null;
if (lastSpawned === spawned) {
lastSpawned = null;
}
});
} else if (interactive && !process.stdin.isPaused()) {
process.stdin.pause();
Expand All @@ -290,25 +301,76 @@ export default autoTrace(
return lastSpawned;
};

// A restart spans kill -> exit -> respawn, during which `lastSpawned` is
// briefly null. Track that window so a request landing in it gets queued
// rather than mistaken for "there is nothing to restart".
let restartInFlight = false;
let restartPending = false;

const restartRunningApp = (): boolean => {
if (restartInFlight) {
d('a restart is already in flight, queueing a follow-up restart');
restartPending = true;
return true;
}

if (!lastSpawned || lastSpawned.restarted) {
d('restart requested, but no Electron app is running');
return false;
}

const dying = lastSpawned;
restartInFlight = true;
console.info(
`${styleText('green', '✔ ')}${styleText('dim', 'Restarting Electron app')}`,
);
dying.restarted = true;
dying.on('exit', () => {
forgeSpawnWrapper().then(
(child) => {
restartInFlight = false;
// Emit on the *exiting* child: its `restarted` listeners are what
// re-attach the CLI's exit handling to the replacement.
dying.emit('restarted', child);

if (restartPending) {
restartPending = false;
restartRunningApp();
}
},
(err) => {
restartInFlight = false;
restartPending = false;
console.error(
styleText(
'red',
'Failed to relaunch the Electron app after a restart, so it is no longer running.',
),
);
console.error(err);
},
);
});
dying.kill('SIGTERM');
return true;
};

setAppRestartHandler(restartRunningApp);

if (interactive) {
process.stdin.on('data', (data) => {
if (
data.toString().trim() === 'rs' &&
lastSpawned &&
!lastSpawned.restarted
) {
if (data.toString().trim() !== 'rs') return;

// Erase the echoed `rs` only when the "Restarting Electron app" line is
// about to take its place; otherwise we would eat a line of the app's
// own output.
if (lastSpawned && !lastSpawned.restarted) {
readline.moveCursor(process.stdout, 0, -1);
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
console.info(
`${styleText('green', '✔ ')}${styleText('dim', 'Restarting Electron app')}`,
);
lastSpawned.restarted = true;
lastSpawned.on('exit', async () => {
lastSpawned!.emit('restarted', await forgeSpawnWrapper());
});
lastSpawned.kill('SIGTERM');
}

requestAppRestart();
});
process.stdin.resume();

Expand Down
32 changes: 32 additions & 0 deletions packages/plugin/vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,35 @@ module.exports = {
]
};
```

### Main process hot restart

Renderer code is hot-reloaded by Vite out of the box, but main process code is
not: the app has to be relaunched to pick it up. Set `hotRestart` to have
`electron-forge start` do that for you whenever a main process bundle rebuilds:

```javascript
// forge.config.js

module.exports = {
plugins: [
{
name: '@electron-forge/plugin-vite',
config: {
hotRestart: true,
build: [
{
entry: 'src/main.js',
config: 'vite.main.config.mjs'
}
],
renderer: []
}
}
]
};
```

This is off by default, so main process changes otherwise only take effect when
you type `rs` in the terminal or restart `electron-forge start`. The option has
no effect when packaging.
28 changes: 24 additions & 4 deletions packages/plugin/vite/spec/ViteConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,35 @@ describe('ViteConfigGenerator', () => {
'electron/main',
]);
expect(buildConfig.clearScreen).toBe(false);
expect(
buildConfig.plugins?.map((plugin) => (plugin as Plugin).name),
).toEqual(['@electron-forge/plugin-vite:hot-restart']);
// Hot restart is opt-in, so the main config carries no plugins by default.
expect(buildConfig.plugins).toEqual([]);
expect(buildConfig.define).toEqual({});
expect(buildConfig.resolve).toEqual({
conditions: ['node'],
mainFields: ['module', 'jsnext:main', 'jsnext'],
});
});

it('getBuildConfigs:main adds the hot restart plugin when hotRestart is enabled', async () => {
const forgeConfig: VitePluginConfig = {
build: [
{
entry: 'src/main.js',
config: path.join(configRoot, 'vite.main.config.mjs'),
target: 'main',
},
],
renderer: [],
hotRestart: true,
};
const generator = new ViteConfigGenerator(forgeConfig, configRoot, true);
const buildConfig = (await generator.getBuildConfigs())[0];

expect(
buildConfig.plugins?.map((plugin) => (plugin as Plugin).name),
).toEqual(['@electron-forge/plugin-vite:hot-restart']);
});

it('getBuildConfigs:preload', async () => {
const forgeConfig: VitePluginConfig = {
build: [
Expand Down Expand Up @@ -89,9 +108,10 @@ describe('ViteConfigGenerator', () => {
assetFileNames: '[name].[ext]',
});
expect(buildConfig.clearScreen).toBe(false);
// Preload scripts are reloaded, never restarted, regardless of `hotRestart`.
expect(
buildConfig.plugins?.map((plugin) => (plugin as Plugin).name),
).toEqual(['@electron-forge/plugin-vite:hot-restart']);
).toEqual(['@electron-forge/plugin-vite:hot-reload']);
});

it('getRendererConfig:renderer', async () => {
Expand Down
Loading
Loading