Skip to content

Commit 5528680

Browse files
authored
fix: prevent infinite app spawn loop in production builds (#28)
* fix: prevent infinite app spawn loop in production builds The server launcher used `spawn(process.execPath, ...)` to start the standalone server process in production. Since process.execPath is the Electron binary, this launched another full Electron app instance instead of a Node.js process, which in turn tried to launch another server, creating an infinite spawn loop. Fix: use Electron's `utilityProcess.fork()` for production builds, which runs the server script as a proper Node.js child process without creating another Electron window. Dev mode continues using `npx tsx` via child_process.spawn. Also adds `app.requestSingleInstanceLock()` as a safety net to prevent multiple app instances from ever running simultaneously. * fix: resolve native module loading in utilityProcess The utilityProcess can't access modules inside the asar archive and doesn't have Electron's ASAR require() patching. This caused three failures in the production build: 1. Server entry detected as index.js but tsup outputs index.cjs (package.json has "type": "module") — add .cjs to isDirectRun check 2. JS dependencies (fastify, pino, etc.) externalized by default — add tsup config with noExternal regex to bundle all JS deps while keeping only native modules (node-pty, better-sqlite3) external 3. Native module resolution broken because Electron's utility_init wraps Module._resolveFilename — patch Module._load instead via tsup banner to intercept require() calls and redirect native modules to their absolute paths in app.asar.unpacked/node_modules/ 4. better-sqlite3 uses require('bindings') which is trapped in the asar — shim the bindings package in the Module._load patch to resolve .node addons directly from known unpacked paths * fix: address PR review feedback - Update comment to say index.cjs instead of index.js - Simplify exit listener — both ChildProcess and UtilityProcess support .on('exit'), no branching needed - Handle bindings object form { bindings: 'name.node' } - Log errors in native module patch instead of silently swallowing
1 parent d033c12 commit 5528680

File tree

5 files changed

+195
-53
lines changed

5 files changed

+195
-53
lines changed

packages/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"./git-utils": "./src/git-utils.ts"
1414
},
1515
"scripts": {
16-
"build": "tsup src/index.ts --format cjs --target node20 --external node-pty --external better-sqlite3 --clean",
16+
"build": "tsup --config tsup.config.ts",
1717
"dev": "tsx src/index.ts"
1818
},
1919
"dependencies": {

packages/server/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ export async function startServer(
104104
}
105105

106106
// Run directly
107-
const isDirectRun = process.argv[1]?.endsWith('index.ts') || process.argv[1]?.endsWith('index.js')
107+
const isDirectRun =
108+
process.argv[1]?.endsWith('index.ts') ||
109+
process.argv[1]?.endsWith('index.js') ||
110+
process.argv[1]?.endsWith('index.cjs')
108111
if (isDirectRun) {
109112
const portArg = process.argv.find((a) => a.startsWith('--port='))
110113
const port = portArg ? parseInt(portArg.split('=')[1], 10) : 0

packages/server/tsup.config.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { defineConfig } from 'tsup'
2+
3+
const NATIVE_MODULE_PATCH = `
4+
// Patch module resolution for Electron's utilityProcess.
5+
//
6+
// utilityProcess doesn't have the main process's ASAR require() patching,
7+
// so bare require('node-pty') fails. Electron wraps Module._resolveFilename
8+
// with its own n._resolveFilename, making Module-level patches unreliable.
9+
//
10+
// Solution: intercept Module._load (the function that actually calls require)
11+
// before Electron can interfere, and redirect native module names to their
12+
// absolute paths in app.asar.unpacked/node_modules/.
13+
//
14+
// The parent process passes VIBEGRID_NATIVE_MODULES_PATH as an env var
15+
// pointing to the unpacked node_modules directory.
16+
//
17+
// Additionally, better-sqlite3 uses require('bindings') to locate its
18+
// .node addon. Since 'bindings' is a JS package trapped inside the asar
19+
// archive (inaccessible to utilityProcess), we intercept it and return a
20+
// shim that resolves directly to the known .node file path.
21+
//
22+
// @see https://electron-vite.org/guide/assets
23+
// @see https://github.com/electron/electron/issues/8727
24+
;(function() {
25+
var nativePath = process.env.VIBEGRID_NATIVE_MODULES_PATH;
26+
if (!nativePath) return;
27+
try {
28+
var Module = require('module');
29+
var path = require('path');
30+
var nativeModules = { 'node-pty': true, 'better-sqlite3': true };
31+
32+
// Map of known native addon .node files for the bindings shim
33+
var knownAddons = {
34+
'better_sqlite3.node': path.join(nativePath, 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node')
35+
};
36+
37+
var origLoad = Module._load;
38+
Module._load = function(request, parent, isMain) {
39+
// Redirect native module requires to unpacked path
40+
if (nativeModules[request]) {
41+
return origLoad.call(this, path.join(nativePath, request), parent, isMain);
42+
}
43+
// Shim the 'bindings' package — return a function that resolves
44+
// addon names from our known unpacked paths
45+
if (request === 'bindings') {
46+
return function(opts) {
47+
// bindings accepts a string or { bindings: 'name.node', ... }
48+
var name = typeof opts === 'object' ? opts.bindings : opts;
49+
if (knownAddons[name]) {
50+
return origLoad.call(Module, knownAddons[name], parent, false);
51+
}
52+
// Fallback: try original bindings module
53+
return origLoad.call(Module, 'bindings', parent, isMain)(opts);
54+
};
55+
}
56+
return origLoad.call(this, request, parent, isMain);
57+
};
58+
} catch(e) { console.error('[native-module-patch] failed:', e); }
59+
})();
60+
`
61+
62+
export default defineConfig({
63+
entry: ['src/index.ts'],
64+
format: ['cjs'],
65+
target: 'node20',
66+
clean: true,
67+
banner: {
68+
js: NATIVE_MODULE_PATCH
69+
},
70+
// Bundle ALL JS dependencies so the server runs standalone in Electron's
71+
// utilityProcess (which cannot access modules inside the asar archive).
72+
//
73+
// Native modules (node-pty, better-sqlite3) remain external because they
74+
// contain compiled .node binaries loaded at runtime from disk.
75+
noExternal: [/^(?!node-pty$|better-sqlite3$)/],
76+
external: ['node-pty', 'better-sqlite3']
77+
})

src/main/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import log from './logger'
1010

1111
let isQuitting = false
1212

13+
// Ensure only one instance of the app runs at a time.
14+
// Without this, spawning bugs (e.g. using process.execPath to launch the server)
15+
// could cause an infinite cascade of Electron app instances.
16+
const gotTheLock = app.requestSingleInstanceLock()
17+
if (!gotTheLock) {
18+
app.quit()
19+
}
20+
1321
// Prevent EPIPE and other uncaught errors from crashing the main process
1422
process.on('uncaughtException', (err) => {
1523
if ((err as NodeJS.ErrnoException).code === 'EPIPE') return
@@ -244,6 +252,15 @@ function updatePermissionShortcuts(): void {
244252
})
245253
}
246254

255+
// When a second instance is launched, focus the existing window instead
256+
app.on('second-instance', () => {
257+
if (mainWindow) {
258+
if (mainWindow.isMinimized()) mainWindow.restore()
259+
mainWindow.show()
260+
mainWindow.focus()
261+
}
262+
})
263+
247264
app.whenReady().then(async () => {
248265
let bridge: ServerBridge
249266
try {

src/main/server/server-launcher.ts

Lines changed: 96 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,102 @@
11
import { spawn, type ChildProcess } from 'node:child_process'
22
import path from 'node:path'
33
import { createInterface } from 'node:readline'
4-
import { app } from 'electron'
4+
import { app, utilityProcess, type UtilityProcess } from 'electron'
55
import log from '../logger'
66
import { ServerBridge } from './server-bridge'
77

8-
let serverProcess: ChildProcess | null = null
8+
let serverProcess: ChildProcess | UtilityProcess | null = null
99
let bridge: ServerBridge | null = null
1010

1111
/**
1212
* Spawns the @vibegrid/server process and returns a connected ServerBridge.
1313
*
1414
* The server writes `{"port": N}` to stdout on startup.
1515
* We read the port, then connect a WebSocket bridge.
16+
*
17+
* In dev mode: uses `npx tsx` via child_process.spawn (TypeScript execution).
18+
* In production: uses Electron's utilityProcess.fork() to run the bundled
19+
* server script as a Node.js process WITHOUT launching another Electron window.
20+
*
21+
* NOTE: Previously this used `spawn(process.execPath, ...)` in production,
22+
* which caused an infinite app spawn loop because process.execPath is the
23+
* Electron binary — spawning it launches another full Electron app instance.
1624
*/
1725
export async function launchServer(): Promise<ServerBridge> {
1826
const serverEntryPoint = resolveServerEntry()
1927
log.info(`[launcher] starting server: ${serverEntryPoint}`)
2028

2129
const dataDir = app.getPath('userData')
22-
2330
const isDev = !!process.env.ELECTRON_RENDERER_URL
24-
const command = isDev ? 'npx' : process.execPath
25-
const args = isDev
26-
? ['tsx', serverEntryPoint, `--data-dir=${dataDir}`]
27-
: [serverEntryPoint, `--data-dir=${dataDir}`]
28-
29-
serverProcess = spawn(command, args, {
30-
stdio: ['pipe', 'pipe', 'pipe'],
31-
env: {
32-
...process.env,
33-
NODE_ENV: process.env.NODE_ENV ?? (isDev ? 'development' : 'production'),
34-
// In production, native modules live in the asar-unpacked node_modules
35-
...(isDev
36-
? {}
37-
: {
38-
NODE_PATH: [
39-
path.join(app.getAppPath(), 'node_modules'),
40-
path.join(app.getAppPath() + '.unpacked', 'node_modules')
41-
].join(path.delimiter)
42-
})
43-
},
44-
cwd: isDev ? path.join(__dirname, '../..') : undefined
45-
})
4631

47-
serverProcess.stdin?.end()
32+
let port: number
33+
34+
if (isDev) {
35+
// Dev mode: use npx tsx to run TypeScript directly
36+
const child = spawn('npx', ['tsx', serverEntryPoint, `--data-dir=${dataDir}`], {
37+
stdio: ['pipe', 'pipe', 'pipe'],
38+
env: {
39+
...process.env,
40+
NODE_ENV: process.env.NODE_ENV ?? 'development'
41+
},
42+
cwd: path.join(__dirname, '../..')
43+
})
44+
45+
child.stdin?.end()
46+
47+
// Forward server stderr to our log
48+
if (child.stderr) {
49+
const errLines = createInterface({ input: child.stderr })
50+
errLines.on('line', (line) => {
51+
log.info(`[server] ${line}`)
52+
})
53+
}
54+
55+
port = await readServerPort(child)
56+
57+
child.on('exit', (code, signal) => {
58+
log.warn(`[launcher] server exited (code=${code}, signal=${signal})`)
59+
serverProcess = null
60+
})
61+
62+
serverProcess = child
63+
} else {
64+
// Production: use Electron's utilityProcess.fork() to run the bundled
65+
// server as a proper Node.js child process (NOT another Electron instance)
66+
//
67+
// The main process has Electron's ASAR patching so it can resolve native
68+
// modules. The utilityProcess does NOT, so we resolve the absolute paths
69+
// here and pass them via environment variables for the server banner to use.
70+
const asarUnpacked = path.join(app.getAppPath() + '.unpacked', 'node_modules')
71+
72+
const child = utilityProcess.fork(serverEntryPoint, [`--data-dir=${dataDir}`], {
73+
stdio: 'pipe',
74+
env: {
75+
...process.env,
76+
NODE_ENV: 'production',
77+
VIBEGRID_NATIVE_MODULES_PATH: asarUnpacked,
78+
NODE_PATH: [path.join(app.getAppPath(), 'node_modules'), asarUnpacked].join(path.delimiter)
79+
}
80+
})
81+
82+
// Forward server stderr to our log
83+
if (child.stderr) {
84+
const errLines = createInterface({ input: child.stderr })
85+
errLines.on('line', (line) => {
86+
log.info(`[server] ${line}`)
87+
})
88+
}
89+
90+
port = await readServerPort(child)
4891

49-
// Forward server stderr to our log
50-
if (serverProcess.stderr) {
51-
const errLines = createInterface({ input: serverProcess.stderr })
52-
errLines.on('line', (line) => {
53-
log.info(`[server] ${line}`)
92+
child.on('exit', (code) => {
93+
log.warn(`[launcher] server exited (code=${code})`)
94+
serverProcess = null
5495
})
96+
97+
serverProcess = child
5598
}
5699

57-
// Read port from server stdout
58-
const port = await readServerPort(serverProcess)
59100
log.info(`[launcher] server started on port ${port}`)
60101

61102
// Connect bridge
@@ -71,12 +112,6 @@ export async function launchServer(): Promise<ServerBridge> {
71112
})
72113
})
73114

74-
// Handle server crash
75-
serverProcess.on('exit', (code, signal) => {
76-
log.warn(`[launcher] server exited (code=${code}, signal=${signal})`)
77-
serverProcess = null
78-
})
79-
80115
return bridge
81116
}
82117

@@ -95,29 +130,37 @@ export async function stopServer(): Promise<void> {
95130
bridge = null
96131
}
97132

98-
if (serverProcess && !serverProcess.killed) {
99-
serverProcess.kill('SIGTERM')
100-
// Force kill after 3 seconds
101-
setTimeout(() => {
102-
if (serverProcess && !serverProcess.killed) {
103-
serverProcess.kill('SIGKILL')
133+
if (serverProcess) {
134+
if ('killed' in serverProcess) {
135+
// ChildProcess (dev mode)
136+
const child = serverProcess as ChildProcess
137+
if (!child.killed) {
138+
child.kill('SIGTERM')
139+
setTimeout(() => {
140+
if (child && !child.killed) {
141+
child.kill('SIGKILL')
142+
}
143+
}, 3000)
104144
}
105-
}, 3000)
145+
} else {
146+
// UtilityProcess (production) — only has kill()
147+
serverProcess.kill()
148+
}
106149
serverProcess = null
107150
}
108151
}
109152

110153
function resolveServerEntry(): string {
111154
// In dev: packages/server/src/index.ts (run via tsx)
112-
// In production: resources/server/index.js (bundled)
155+
// In production: resources/server/index.cjs (bundled)
113156
if (process.env.ELECTRON_RENDERER_URL) {
114157
// Dev mode — use tsx to run TypeScript directly
115158
return path.join(__dirname, '../../packages/server/src/index.ts')
116159
}
117-
return path.join(process.resourcesPath, 'server', 'index.js')
160+
return path.join(process.resourcesPath, 'server', 'index.cjs')
118161
}
119162

120-
function readServerPort(child: ChildProcess): Promise<number> {
163+
function readServerPort(child: ChildProcess | UtilityProcess): Promise<number> {
121164
return new Promise((resolve, reject) => {
122165
if (!child.stdout) {
123166
reject(new Error('No stdout on server process'))
@@ -142,9 +185,11 @@ function readServerPort(child: ChildProcess): Promise<number> {
142185
}
143186
})
144187

145-
child.on('exit', (code) => {
188+
// Both ChildProcess and UtilityProcess support .on('exit', cb)
189+
const onExit = (code: number | null) => {
146190
clearTimeout(timeout)
147191
reject(new Error(`Server exited before reporting port (code=${code})`))
148-
})
192+
}
193+
;(child as UtilityProcess).on('exit', onExit)
149194
})
150195
}

0 commit comments

Comments
 (0)