ソースを参照

feat(desktop): move server to utilityProcess (#25962)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Brendan Allan 4 ヶ月 前
コミット
54a78c9224

+ 1 - 1
packages/desktop/electron.vite.config.ts

@@ -37,7 +37,7 @@ export default defineConfig({
     },
     build: {
       rollupOptions: {
-        input: { index: "src/main/index.ts" },
+        input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
       },
       externalizeDeps: { include: [nodePtyPkg] },
     },

+ 31 - 24
packages/desktop/src/main/apps.ts

@@ -1,14 +1,22 @@
-import { execFileSync } from "node:child_process"
-import { existsSync, readFileSync, readdirSync } from "node:fs"
+import { execFile, execFileSync } from "node:child_process"
+import { access, readFile, readdir } from "node:fs/promises"
 import { dirname, extname, join } from "node:path"
+import util from "node:util"
 
-export function checkAppExists(appName: string): boolean {
+const execFilePromise = util.promisify(execFile)
+
+const exists = (path: string) =>
+  access(path)
+    .then(() => true)
+    .catch(() => false)
+
+export function checkAppExists(appName: string) {
   if (process.platform === "win32") return true
   if (process.platform === "linux") return true
   return checkMacosApp(appName)
 }
 
-export function resolveAppPath(appName: string): string | null {
+export function resolveAppPath(appName: string) {
   if (process.platform !== "win32") return appName
   return resolveWindowsAppPath(appName)
 }
@@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
   }
 }
 
-function checkMacosApp(appName: string) {
+async function checkMacosApp(appName: string) {
   const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
 
   const home = process.env.HOME
   if (home) locations.push(`${home}/Applications/${appName}.app`)
 
-  if (locations.some((location) => existsSync(location))) return true
-
-  try {
-    execFileSync("which", [appName])
-    return true
-  } catch {
-    return false
+  for (const location of locations) {
+    if (await exists(location)) return true
   }
+
+  return execFilePromise("which", [appName])
+    .then(() => true)
+    .catch(() => false)
 }
 
-function resolveWindowsAppPath(appName: string): string | null {
+async function resolveWindowsAppPath(appName: string): Promise<string | null> {
   let output: string
   try {
-    output = execFileSync("where", [appName]).toString()
+    output = execFilePromise("where", [appName]).toString()
   } catch {
     return null
   }
@@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null {
   const exe = paths.find((path) => hasExt(path, "exe"))
   if (exe) return exe
 
-  const resolveCmd = (path: string) => {
-    const content = readFileSync(path, "utf8")
+  const resolveCmd = async (path: string) => {
+    const content = await readFile(path, "utf8")
     for (const token of content.split('"').map((value: string) => value.trim())) {
       const lower = token.toLowerCase()
       if (!lower.includes(".exe")) continue
@@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null {
             return join(current, part)
           }, base)
 
-        if (existsSync(resolved)) return resolved
+        if (await exists(resolved)) return resolved
       }
 
-      if (existsSync(token)) return token
+      if (await exists(token)) return token
     }
 
     return null
@@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null {
 
   for (const path of paths) {
     if (hasExt(path, "cmd") || hasExt(path, "bat")) {
-      const resolved = resolveCmd(path)
+      const resolved = await resolveCmd(path)
       if (resolved) return resolved
     }
 
     if (!extname(path)) {
       const cmd = `${path}.cmd`
-      if (existsSync(cmd)) {
-        const resolved = resolveCmd(cmd)
+      if (await exists(cmd)) {
+        const resolved = await resolveCmd(cmd)
         if (resolved) return resolved
       }
 
       const bat = `${path}.bat`
-      if (existsSync(bat)) {
-        const resolved = resolveCmd(bat)
+      if (await exists(bat)) {
+        const resolved = await resolveCmd(bat)
         if (resolved) return resolved
       }
     }
@@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null {
       const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
       for (const dir of dirs) {
         try {
-          for (const entry of readdirSync(dir)) {
+          for (const entry of await readdir(dir)) {
             const candidate = join(dir, entry)
             if (!hasExt(candidate, "exe")) continue
             const stem = entry.replace(/\.exe$/i, "")

+ 1 - 0
packages/desktop/src/main/env.d.ts

@@ -5,6 +5,7 @@ interface ImportMetaEnv {
 interface ImportMeta {
   readonly env: ImportMetaEnv
 }
+
 declare module "virtual:opencode-server" {
   export namespace Server {
     export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen

+ 41 - 38
packages/desktop/src/main/index.ts

@@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
 import { initLogging } from "./logging"
 import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
-import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
+import {
+  getDefaultServerUrl,
+  getWslConfig,
+  preferAppEnv,
+  setDefaultServerUrl,
+  setWslConfig,
+  spawnLocalServer,
+  type SidecarListener,
+} from "./server"
 import {
   createLoadingWindow,
   createMainWindow,
@@ -55,15 +63,13 @@ import {
   setBackgroundColor,
   setDockIcon,
 } from "./windows"
-import { drizzle } from "drizzle-orm/node-sqlite/driver"
-import type { Server } from "virtual:opencode-server"
 import { migrate } from "./migrate"
 
 const initEmitter = new EventEmitter()
 let initStep: InitStep = { phase: "server_waiting" }
 
 let mainWindow: BrowserWindow | null = null
-let server: Server.Listener | null = null
+let server: SidecarListener | null = null
 const loadingComplete = defer<void>()
 
 const pendingDeepLinks: string[] = []
@@ -107,6 +113,8 @@ function setupApp() {
     return
   }
 
+  preferAppEnv(app.getPath("userData"))
+
   app.on("second-instance", (_event: Event, argv: string[]) => {
     const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
     if (urls.length) {
@@ -123,17 +131,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))
     })
   }
 
@@ -184,7 +191,6 @@ function setInitStep(step: InitStep) {
 
 async function initialize() {
   const needsMigration = !sqliteFileExists()
-  const sqliteDone = needsMigration ? defer<void>() : undefined
   let overlay: BrowserWindow | null = null
 
   const port = await getSidecarPort()
@@ -199,31 +205,26 @@ async function initialize() {
       setInitStep({ phase: "sqlite_waiting" })
       if (overlay) sendSqliteMigrationProgress(overlay, progress)
       if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
-      if (progress.type === "Done") sqliteDone?.resolve()
     })
 
-    if (needsMigration) {
-      const { Database, JsonMigration } = await import("virtual:opencode-server")
-      await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
-        progress: (event: { current: number; total: number }) => {
-          const percent = Math.round(event.current / event.total) * 100
-          initEmitter.emit("sqlite", { type: "InProgress", value: percent })
-        },
-      })
-      initEmitter.emit("sqlite", { type: "Done" })
-
-      sqliteDone?.resolve()
-    }
-
-    if (needsMigration) {
-      await sqliteDone?.promise
-    }
-
     logger.log("spawning sidecar", { url })
-    const { listener, health } = await spawnLocalServer(hostname, port, password, () => {
-      ensureLoopbackNoProxy()
-      useEnvProxy()
-    })
+    const { listener, health } = await spawnLocalServer(
+      hostname,
+      port,
+      password,
+      () => {
+        ensureLoopbackNoProxy()
+        useEnvProxy()
+      },
+      {
+        needsMigration,
+        userDataPath: app.getPath("userData"),
+        onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
+        onStdout: (message) => logger.log("sidecar stdout", { message }),
+        onStderr: (message) => logger.warn("sidecar stderr", { message }),
+        onExit: (code) => logger.warn("sidecar exited", { code }),
+      },
+    )
     server = listener
     serverReady.resolve({
       url,
@@ -273,9 +274,10 @@ function wireMenu() {
     },
     reload: () => mainWindow?.reload(),
     relaunch: () => {
-      killSidecar()
-      app.relaunch()
-      app.exit(0)
+      void killSidecar().finally(() => {
+        app.relaunch()
+        app.exit(0)
+      })
     },
   })
 }
@@ -304,7 +306,7 @@ registerIpcHandlers({
   getDisplayBackend: async () => null,
   setDisplayBackend: async () => undefined,
   parseMarkdown: async (markdown) => parseMarkdown(markdown),
-  checkAppExists: async (appName) => checkAppExists(appName),
+  checkAppExists: (appName) => checkAppExists(appName),
   wslPath: async (path, mode) => wslPath(path, mode),
   resolveAppPath: async (appName) => resolveAppPath(appName),
   loadingWindowComplete: () => loadingComplete.resolve(),
@@ -314,10 +316,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() {
@@ -440,7 +443,7 @@ async function installUpdate() {
   logger.log("installing downloaded update", {
     version: downloadedUpdateVersion,
   })
-  killSidecar()
+  await killSidecar()
   autoUpdater.quitAndInstall()
 }
 

+ 1 - 1
packages/desktop/src/main/ipc.ts

@@ -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[]

+ 188 - 29
packages/desktop/src/main/server.ts

@@ -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 }
+}

+ 178 - 0
packages/desktop/src/main/sidecar.ts

@@ -0,0 +1,178 @@
+import { drizzle } from "drizzle-orm/node-sqlite/driver"
+import * as http from "node:http"
+import * as tls from "node:tls"
+
+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
+  port: number
+  password: string
+  userDataPath: string
+  needsMigration: boolean
+}
+
+type StopCommand = { type: "stop" }
+type SidecarCommand = StartCommand | StopCommand
+
+type SidecarMessage =
+  | { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
+  | { type: "ready" }
+  | { type: "stopped" }
+  | { type: "error"; error: { message: string; stack?: string } }
+
+type ParentPort = {
+  postMessage(message: SidecarMessage): void
+  on(event: "message", listener: (event: { data: unknown }) => void): void
+}
+
+type Listener = {
+  stop(close?: boolean): void | Promise<void>
+}
+
+const parentPort = getParentPort()
+let listener: Listener | undefined
+
+parentPort.on("message", (event) => {
+  const command = parseCommand(event.data)
+  if (!command) return
+  if (command.type === "stop") {
+    void stop()
+    return
+  }
+  void start(command)
+})
+
+async function start(command: StartCommand) {
+  try {
+    prepareSidecarEnv(command.password, command.userDataPath)
+    ensureLoopbackNoProxy()
+    useSystemCertificates()
+    useEnvProxy()
+    const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
+    await Log.init({ level: "WARN" })
+
+    if (command.needsMigration) {
+      await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
+        progress: (event: { current: number; total: number }) => {
+          parentPort.postMessage({
+            type: "sqlite",
+            progress: {
+              type: "InProgress",
+              value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100),
+            },
+          })
+        },
+      })
+      parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } })
+    }
+
+    listener = await Server.listen({
+      port: command.port,
+      hostname: command.hostname,
+      username: "opencode",
+      password: command.password,
+      cors: ["oc://renderer"],
+    })
+    parentPort.postMessage({ type: "ready" })
+  } catch (error) {
+    parentPort.postMessage({ type: "error", error: serializeError(error) })
+    setImmediate(() => process.exit(1))
+  }
+}
+
+async function stop() {
+  try {
+    await listener?.stop()
+  } finally {
+    listener = undefined
+    parentPort.postMessage({ type: "stopped" })
+    setImmediate(() => process.exit(0))
+  }
+}
+
+function prepareSidecarEnv(password: string, userDataPath: string) {
+  Object.assign(process.env, {
+    OPENCODE_SERVER_USERNAME: "opencode",
+    OPENCODE_SERVER_PASSWORD: password,
+    XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
+  })
+}
+
+function ensureLoopbackNoProxy() {
+  const loopback = ["127.0.0.1", "localhost", "::1"]
+  const upsert = (key: string) => {
+    const items = (process.env[key] ?? "")
+      .split(",")
+      .map((value: string) => value.trim())
+      .filter((value: string) => Boolean(value))
+
+    for (const host of loopback) {
+      if (items.some((value: string) => value.toLowerCase() === host)) continue
+      items.push(host)
+    }
+
+    process.env[key] = items.join(",")
+  }
+
+  upsert("NO_PROXY")
+  upsert("no_proxy")
+}
+
+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>
+  if (command.type === "stop") return { type: "stop" }
+  if (command.type !== "start") return
+  if (typeof command.hostname !== "string") return
+  if (typeof command.port !== "number") return
+  if (typeof command.password !== "string") return
+  if (typeof command.userDataPath !== "string") return
+  if (typeof command.needsMigration !== "boolean") return
+  return {
+    type: "start",
+    hostname: command.hostname,
+    port: command.port,
+    password: command.password,
+    userDataPath: command.userDataPath,
+    needsMigration: command.needsMigration,
+  }
+}
+
+function serializeError(error: unknown) {
+  if (error instanceof Error) return { message: error.message, stack: error.stack }
+  return { message: String(error) }
+}
+
+function getParentPort() {
+  const port = process.parentPort as ParentPort | undefined
+  if (!port) throw new Error("Sidecar parent port unavailable")
+  return port
+}