Unified · Split
| @@ -5,9 +5,9 @@ · 4 unmodified lines | |||
| 5 | * In prod: spawns the bundled CLI binary in foreground daemon mode. | 5 | * In prod: spawns the bundled CLI binary in foreground daemon mode. |
| 6 | * | 6 | * |
| 7 | * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN. | 7 | * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN. |
| 8 | * The dev sidecar and packaged CLI child announce structured stdout sentinels | 8 | * The dev sidecar and packaged CLI child announce the structured stdout sentinel |
| 9 | * (`EXECUTOR_READY:<port>` or `EXECUTOR_ATTACHED`). Human-readable log text is | 9 | * `EXECUTOR_READY:<port>`. Human-readable log text is never part of the desktop |
| 10 | * never part of the desktop startup contract. | 10 | * startup contract. |
| 11 | */ | 11 | */ |
| 12 | 12 | ||
| 13 | import { spawn, type ChildProcess } from "node:child_process"; | 13 | import { spawn, type ChildProcess } from "node:child_process"; |
| @@ -36,9 +36,6 @@ const sidecarLog = log.scope("sidecar"); · 22 unmodified lines | |||
| 36 | // sidecar can't grow it unbounded over a long session. | 36 | // sidecar can't grow it unbounded over a long session. |
| 37 | const STDERR_TAIL_LIMIT = 8 * 1024; | 37 | const STDERR_TAIL_LIMIT = 8 * 1024; |
| 38 | const READY_SENTINEL = "EXECUTOR_READY"; | 38 | const READY_SENTINEL = "EXECUTOR_READY"; |
| 39 | const ATTACHED_SENTINEL = "EXECUTOR_ATTACHED"; | ||
| 40 | const ATTACH_OWNER_TIMEOUT_MS = 15_000; | ||
| 41 | const ATTACH_OWNER_POLL_MS = 150; | ||
| 42 | 39 | ||
| 43 | // Children deliberately stopped via stopSidecar (quit, restart, update) — | 40 | // Children deliberately stopped via stopSidecar (quit, restart, update) — |
| 44 | // their exits are expected and must not be reported as crashes. | 41 | // their exits are expected and must not be reported as crashes. |
| @@ -227,12 +224,6 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 182 unmodified lines | |||
| 227 | const dataDir = scopeDir; | 224 | const dataDir = scopeDir; |
| 228 | mkdirSync(dataDir, { recursive: true }); | 225 | mkdirSync(dataDir, { recursive: true }); |
| 229 | 226 | ||
| 230 | // Fast path: a reachable local server already owns this data dir (a CLI | ||
| 231 | // daemon, another desktop sidecar, or a supervised service). Attach to it | ||
| 232 | // instead of spawning a child that would only lose the DB ownership race. | ||
| 233 | const attached = await attachToReachableLocalServer(dataDir); | ||
| 234 | if (attached) return attached; | ||
| 235 | |||
| 236 | // The stable bearer token from auth.json (shared with the CLI). The main | 227 | // The stable bearer token from auth.json (shared with the CLI). The main |
| 237 | // process holds it so it can inject the header into the webview; the child | 228 | // process holds it so it can inject the header into the webview; the child |
| 238 | // validates against the same value. Always present — auth is unconditional. | 229 | // validates against the same value. Always present — auth is unconditional. |
| @@ -253,8 +244,8 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 14 unmodified lines | |||
| 253 | 244 | ||
| 254 | // No process-level startup lock: the dev sidecar child opens the DB through | 245 | // No process-level startup lock: the dev sidecar child opens the DB through |
| 255 | // openOwnedLocalDatabase, whose ownership lock is the real gate. If the child | 246 | // openOwnedLocalDatabase, whose ownership lock is the real gate. If the child |
| 256 | // loses the race it exits and onExit attaches to the winner. server.json is an | 247 | // loses the race, startup fails as before; only the packaged supervised boot |
| 257 | // attach hint only. | 248 | // path attaches to an existing daemon. |
| 258 | const webBaseUrl = `http://${hostname}:${settings.port}`; | 249 | const webBaseUrl = `http://${hostname}:${settings.port}`; |
| 259 | const child = spawn(command, args, { | 250 | const child = spawn(command, args, { |
| 260 | cwd, | 251 | cwd, |
| @@ -284,7 +275,6 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 23 unmodified lines | |||
| 284 | let stdoutControlBuffer = ""; | 275 | let stdoutControlBuffer = ""; |
| 285 | let resolved = false; | 276 | let resolved = false; |
| 286 | let rejected = false; | 277 | let rejected = false; |
| 287 | let attachedOwnerResolution: Promise<boolean> | null = null; | ||
| 288 | 278 | ||
| 289 | const logStdoutLine = makeLineSplitter((line) => sidecarLog.info(line)); | 279 | const logStdoutLine = makeLineSplitter((line) => sidecarLog.info(line)); |
| 290 | const logStderrLine = makeLineSplitter((line) => sidecarLog.error(line)); | 280 | const logStderrLine = makeLineSplitter((line) => sidecarLog.error(line)); |
| @@ -296,25 +286,6 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines | |||
| 296 | rejectStart(err); | 286 | rejectStart(err); |
| 297 | }; | 287 | }; |
| 298 | 288 | ||
| 299 | const resolveAttachedOwner = async (): Promise<boolean> => { | ||
| 300 | if (resolved || rejected) return true; | ||
| 301 | if (!attachedOwnerResolution) { | ||
| 302 | attachedOwnerResolution = waitForReachableLocalServer(dataDir) | ||
| 303 | .then((attached) => { | ||
| 304 | if (resolved || rejected) return true; | ||
| 305 | if (!attached) return false; | ||
| 306 | resolved = true; | ||
| 307 | expectedExits.add(child); | ||
| 308 | resolveStart(attached); | ||
| 309 | return true; | ||
| 310 | }) | ||
| 311 | .finally(() => { | ||
| 312 | attachedOwnerResolution = null; | ||
| 313 | }); | ||
| 314 | } | ||
| 315 | return attachedOwnerResolution; | ||
| 316 | }; | ||
| 317 | |||
| 318 | const onStdout = (chunk: Buffer) => { | 289 | const onStdout = (chunk: Buffer) => { |
| 319 | const text = chunk.toString("utf8"); | 290 | const text = chunk.toString("utf8"); |
| 320 | process.stdout.write(`[executor-server] ${text}`); | 291 | process.stdout.write(`[executor-server] ${text}`); |
| @@ -326,10 +297,6 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines | |||
| 326 | .map((line) => line.trim()) | 297 | .map((line) => line.trim()) |
| 327 | .filter((line) => line.length > 0); | 298 | .filter((line) => line.length > 0); |
| 328 | const readyLine = stdoutLines.find((line) => line.startsWith(`${READY_SENTINEL}:`)); | 299 | const readyLine = stdoutLines.find((line) => line.startsWith(`${READY_SENTINEL}:`)); |
| 329 | if (stdoutLines.includes(ATTACHED_SENTINEL) && !resolved) { | ||
| 330 | void resolveAttachedOwner(); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | if (readyLine && !resolved) { | 300 | if (readyLine && !resolved) { |
| 334 | if (!child.pid) { | 301 | if (!child.pid) { |
| 335 | reject( | 302 | reject( |
| @@ -388,18 +355,15 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 52 unmodified lines | |||
| 388 | return; | 355 | return; |
| 389 | } | 356 | } |
| 390 | if (rejected) return; | 357 | if (rejected) return; |
| 391 | void (async () => { | 358 | // Detect bind failure — the Node listener prints either "EADDRINUSE" or |
| 392 | if (await resolveAttachedOwner()) return; | 359 | // "address already in use" on stderr before exiting non-zero. |
| 393 | // Detect bind failure — the Node listener prints either "EADDRINUSE" or | 360 | if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) { |
| 394 | // "address already in use" on stderr before exiting non-zero. | 361 | reject(new SidecarPortInUseError(settings.port)); |
| 395 | if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) { | 362 | return; |
| 396 | reject(new SidecarPortInUseError(settings.port)); | 363 | } |
| 397 | return; | 364 | const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`; |
| 398 | } | 365 | // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise |
| 399 | const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`; | 366 | reject(new Error(message)); |
| 400 | // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise | ||
| 401 | reject(new Error(message)); | ||
| 402 | })(); | ||
| 403 | }; | 367 | }; |
| 404 | 368 | ||
| 405 | child.stdout?.on("data", onStdout); | 369 | child.stdout?.on("data", onStdout); |
| @@ -431,61 +395,6 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 25 unmodified lines | |||
| 431 | return false; | 395 | return false; |
| 432 | }; | 396 | }; |
| 433 | 397 | ||
| 434 | const waitForReachableLocalServer = async ( | ||
| 435 | dataDir: string, | ||
| 436 | options: { readonly requireCliDaemon?: boolean } = {}, | ||
| 437 | ): Promise<SidecarConnection | null> => { | ||
| 438 | const deadline = Date.now() + ATTACH_OWNER_TIMEOUT_MS; | ||
| 439 | while (Date.now() <= deadline) { | ||
| 440 | const attached = await attachToReachableLocalServer(dataDir, options); | ||
| 441 | if (attached) return attached; | ||
| 442 | const remaining = deadline - Date.now(); | ||
| 443 | if (remaining <= 0) return null; | ||
| 444 | await delay(Math.min(ATTACH_OWNER_POLL_MS, remaining)); | ||
| 445 | } | ||
| 446 | return null; | ||
| 447 | }; | ||
| 448 | |||
| 449 | const attachToReachableLocalServer = async ( | ||
| 450 | dataDir: string, | ||
| 451 | options: { readonly requireCliDaemon?: boolean } = {}, | ||
| 452 | ): Promise<SidecarConnection | null> => { | ||
| 453 | const manifest = readManifest(dataDir); | ||
| 454 | if (!manifest) return null; | ||
| 455 | if (options.requireCliDaemon && manifest.kind !== "cli-daemon") return null; | ||
| 456 | if (!isPidAlive(manifest.pid)) { | ||
| 457 | removeManifestIfOwnedBy(dataDir, manifest.pid); | ||
| 458 | return null; | ||
| 459 | } | ||
| 460 | |||
| 461 | const origin = manifest.connection.origin; | ||
| 462 | const auth = manifest.connection.auth; | ||
| 463 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; | ||
| 464 | if (!(await isDaemonReachable(origin))) { | ||
| 465 | sidecarLog.warn( | ||
| 466 | `local Executor ${manifest.kind} at ${origin} (pid ${manifest.pid}) did not answer the health probe; keeping its manifest because the process is still alive`, | ||
| 467 | ); | ||
| 468 | return null; | ||
| 469 | } | ||
| 470 | |||
| 471 | const url = new URL(origin); | ||
| 472 | sidecarLog.info( | ||
| 473 | `attaching to local Executor ${manifest.kind} at ${origin} (pid ${manifest.pid})`, | ||
| 474 | ); | ||
| 475 | return { | ||
| 476 | baseUrl: origin, | ||
| 477 | hostname: url.hostname, | ||
| 478 | port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), | ||
| 479 | username: SERVER_SETTINGS_USERNAME, | ||
| 480 | authToken, | ||
| 481 | child: null, | ||
| 482 | supervisedDaemon: manifest.kind === "cli-daemon", | ||
| 483 | ownerVersion: manifest.owner.version, | ||
| 484 | ownerClient: manifest.owner.client, | ||
| 485 | ownerExecutablePath: manifest.owner.executablePath, | ||
| 486 | }; | ||
| 487 | }; | ||
| 488 | |||
| 489 | /** | 398 | /** |
| 490 | * Attach to an already-running OS-supervised daemon instead of spawning our own | 399 | * Attach to an already-running OS-supervised daemon instead of spawning our own |
| 491 | * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a | 400 | * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a |