feat(desktop): move server to utilityProcess (#25962)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
co-authored by
LukeParkerDev
parent
9b30ee2db2
commit
54a78c9224
@@ -1,12 +1,37 @@
|
||||
import { app } from "electron"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "sqlite"; progress: SqliteMigrationProgress }
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
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
|
||||
userDataPath: string
|
||||
onSqliteProgress?: (progress: SqliteMigrationProgress) => void
|
||||
onStdout?: (message: string) => void
|
||||
onStderr?: (message: string) => void
|
||||
onExit?: (code: number) => void
|
||||
}
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
@@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) {
|
||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) {
|
||||
prepareServerEnv(password)
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(
|
||||
process.env,
|
||||
mergeShellEnv(shell ? loadShellEnv(shell) : null, {
|
||||
...process.env,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(
|
||||
hostname: string,
|
||||
port: number,
|
||||
password: string,
|
||||
configureEnv: () => void,
|
||||
options: SpawnLocalServerOptions,
|
||||
) {
|
||||
configureEnv?.()
|
||||
const { Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
cors: ["oc://renderer"],
|
||||
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
|
||||
const child = utilityProcess.fork(sidecar, [], {
|
||||
cwd: process.cwd(),
|
||||
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") {
|
||||
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||
}
|
||||
}
|
||||
const onExit = (code: number) => {
|
||||
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,
|
||||
port,
|
||||
password,
|
||||
userDataPath: options.userDataPath,
|
||||
needsMigration: options.needsMigration,
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (!exited) child.kill()
|
||||
throw error
|
||||
})
|
||||
|
||||
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])
|
||||
})()
|
||||
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
let stopping: Promise<void> | undefined
|
||||
|
||||
function prepareServerEnv(password: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"),
|
||||
return {
|
||||
listener: {
|
||||
stop: () => {
|
||||
if (stopping) return stopping
|
||||
if (exited) return Promise.resolve()
|
||||
child.postMessage({ type: "stop" })
|
||||
stopping = Promise.race([
|
||||
exit.promise.then(() => undefined),
|
||||
delay(SIDECAR_STOP_TIMEOUT).then(() => {
|
||||
if (!exited) child.kill()
|
||||
}),
|
||||
])
|
||||
return stopping
|
||||
},
|
||||
},
|
||||
health: { wait },
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
@@ -100,3 +231,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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user