fix(desktop): harden sidecar lifecycle
This commit is contained in:
@@ -113,17 +113,16 @@ function setupApp() {
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
killSidecar()
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
void killSidecar().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,9 +259,10 @@ function wireMenu() {
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
killSidecar()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
void killSidecar().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -301,10 +301,11 @@ registerIpcHandlers({
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
async function killSidecar() {
|
||||
if (!server) return
|
||||
server.stop()
|
||||
const current = server
|
||||
server = null
|
||||
await current.stop()
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
@@ -425,7 +426,7 @@ async function installUpdate() {
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
killSidecar()
|
||||
await killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => {
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
killSidecar: () => Promise<void> | void
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { utilityProcess } from "electron"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getStore } from "./store"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
@@ -15,7 +16,11 @@ type SidecarMessage =
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
export type SidecarListener = { stop: () => void }
|
||||
export type SidecarListener = { stop: () => Promise<void> }
|
||||
|
||||
const SIDECAR_SERVICE_NAME = "opencode server"
|
||||
const SIDECAR_START_STALL_TIMEOUT = 60_000
|
||||
const SIDECAR_STOP_TIMEOUT = 6_000
|
||||
|
||||
type SpawnLocalServerOptions = {
|
||||
needsMigration: boolean
|
||||
@@ -57,43 +62,80 @@ export async function spawnLocalServer(
|
||||
options: SpawnLocalServerOptions,
|
||||
) {
|
||||
configureEnv?.()
|
||||
const child = utilityProcess.fork(join(dirname(fileURLToPath(import.meta.url)), "sidecar.js"), [], {
|
||||
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
|
||||
const child = utilityProcess.fork(sidecar, [], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
serviceName: "opencode server",
|
||||
env: createSidecarEnv(),
|
||||
serviceName: SIDECAR_SERVICE_NAME,
|
||||
stdio: "pipe",
|
||||
})
|
||||
let exited = false
|
||||
const exit = defer<number>()
|
||||
|
||||
const onProcessGone = (_event: unknown, details: Details) => {
|
||||
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
|
||||
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
|
||||
}
|
||||
|
||||
app.on("child-process-gone", onProcessGone)
|
||||
child.once("exit", (code) => {
|
||||
exited = true
|
||||
app.off("child-process-gone", onProcessGone)
|
||||
options.onExit?.(code)
|
||||
exit.resolve(code)
|
||||
})
|
||||
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
|
||||
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const refreshTimeout = () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
|
||||
}, SIDECAR_START_STALL_TIMEOUT)
|
||||
}
|
||||
|
||||
const onMessage = (message: SidecarMessage) => {
|
||||
if (message.type === "sqlite") {
|
||||
refreshTimeout()
|
||||
options.onSqliteProgress?.(message.progress)
|
||||
return
|
||||
}
|
||||
if (message.type === "ready") {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (message.type === "error") {
|
||||
cleanup()
|
||||
reject(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||
}
|
||||
}
|
||||
const onExit = (code: number) => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar exited before ready with code ${code}`))
|
||||
fail(new Error(`Sidecar exited before ready with code ${code}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.off("message", onMessage)
|
||||
child.off("exit", onExit)
|
||||
}
|
||||
|
||||
child.on("message", onMessage)
|
||||
child.on("exit", onExit)
|
||||
refreshTimeout()
|
||||
child.postMessage({
|
||||
type: "start",
|
||||
hostname,
|
||||
@@ -102,29 +144,47 @@ export async function spawnLocalServer(
|
||||
userDataPath: options.userDataPath,
|
||||
needsMigration: options.needsMigration,
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (!exited) child.kill()
|
||||
throw error
|
||||
})
|
||||
child.on("exit", (code: number) => options.onExit?.(code))
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
let healthy = false
|
||||
const gone = exit.promise.then((code) => {
|
||||
if (healthy) return
|
||||
throw new Error(`Sidecar exited before health check passed with code ${code}`)
|
||||
})
|
||||
|
||||
const ready = async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) return
|
||||
if (await checkHealth(url, password)) {
|
||||
healthy = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ready()
|
||||
await Promise.race([ready(), gone])
|
||||
})()
|
||||
|
||||
let stopping: Promise<void> | undefined
|
||||
|
||||
return {
|
||||
listener: {
|
||||
stop: () => {
|
||||
if (stopping) return stopping
|
||||
if (exited) return Promise.resolve()
|
||||
child.postMessage({ type: "stop" })
|
||||
setTimeout(() => {
|
||||
if (child.pid) child.kill()
|
||||
}, 2_000).unref()
|
||||
stopping = Promise.race([
|
||||
exit.promise.then(() => undefined),
|
||||
delay(SIDECAR_STOP_TIMEOUT).then(() => {
|
||||
if (!exited) child.kill()
|
||||
}),
|
||||
])
|
||||
return stopping
|
||||
},
|
||||
},
|
||||
health: { wait },
|
||||
@@ -156,3 +216,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createSidecarEnv(): Record<string, string> {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||
)
|
||||
delete env.DEBUG
|
||||
if (process.platform === "linux") delete env.LD_PRELOAD
|
||||
return env
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import * as http from "node:http"
|
||||
import * as tls from "node:tls"
|
||||
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
|
||||
type NodeHttpWithEnvProxy = typeof http & {
|
||||
setGlobalProxyFromEnv: () => void
|
||||
}
|
||||
|
||||
type NodeTlsWithSystemCertificates = typeof tls & {
|
||||
getCACertificates: (type: "default" | "system") => string[]
|
||||
setDefaultCACertificates: (certificates: string[]) => void
|
||||
}
|
||||
|
||||
type StartCommand = {
|
||||
type: "start"
|
||||
hostname: string
|
||||
@@ -45,6 +56,8 @@ parentPort.on("message", (event) => {
|
||||
async function start(command: StartCommand) {
|
||||
try {
|
||||
prepareServerEnv(command.password, command.userDataPath)
|
||||
useSystemCertificates()
|
||||
useEnvProxy()
|
||||
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
|
||||
@@ -73,6 +86,7 @@ async function start(command: StartCommand) {
|
||||
parentPort.postMessage({ type: "ready" })
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: "error", error: serializeError(error) })
|
||||
setImmediate(() => process.exit(1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +115,25 @@ function prepareServerEnv(password: string, userDataPath: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function useSystemCertificates() {
|
||||
try {
|
||||
const nodeTls = tls as NodeTlsWithSystemCertificates
|
||||
nodeTls.setDefaultCACertificates([
|
||||
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
console.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommand(value: unknown): SidecarCommand | undefined {
|
||||
if (!value || typeof value !== "object") return
|
||||
const command = value as Partial<StartCommand | StopCommand>
|
||||
|
||||
Reference in New Issue
Block a user