From fb566141d3f96a91194a61cd921af3ea3ac751f4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Jul 2026 22:46:09 +0000 Subject: [PATCH] fix(cli): release service startup lock --- packages/cli/src/server-process.ts | 9 +++++++-- packages/cli/test/service.test.ts | 28 +++++++++++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index efd5a38b32..594ec954ae 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -10,7 +10,7 @@ import { AppProcess } from "@opencode-ai/core/process" import { ProcessLock } from "@opencode-ai/core/util/process-lock" import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" -import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect" +import { Effect, Exit, FileSystem, Logger, Option, Redacted, Schedule, Schema, Scope } from "effect" import { HttpServer } from "effect/unstable/http" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" @@ -38,8 +38,12 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { return yield* Effect.scoped( Effect.gen(function* () { const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined - if (serviceOptions !== undefined) { + const lockScope = serviceOptions === undefined ? undefined : yield* Scope.make() + if (lockScope !== undefined) yield* Effect.addFinalizer((exit) => Scope.close(lockScope, exit)) + if (serviceOptions !== undefined && lockScope !== undefined) { + if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe( + Effect.provideService(Scope.Scope, lockScope), Effect.as(true), Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)), ) @@ -72,6 +76,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ? undefined : { onListen: (address) => register(address, password, instanceID, serviceOptions.file) }, }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) + if (lockScope !== undefined) yield* Scope.close(lockScope, Exit.void) const url = HttpServer.formatAddress(server.address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 3f6225b383..653cfb44e3 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -10,6 +10,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionTable } from "@opencode-ai/core/session/sql" +import { ProcessLock } from "@opencode-ai/core/util/process-lock" import { expect, test } from "bun:test" import { Effect, Schedule, Schema } from "effect" import fs from "node:fs/promises" @@ -142,15 +143,23 @@ test("concurrent service processes elect one server", async () => { expect(exited).toEqual(losers.map(() => true)) expect(winner?.exitCode).toBe(null) - expect( - await fetch(new URL("/api/health", info.url), { - headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) }, - }).then((response) => response.json()), - ).toEqual({ - healthy: true, - version: info.version, - pid: info.pid, - }) + await Effect.runPromise( + Effect.gen(function* () { + yield* ProcessLock.acquire(registration + ".lock") + expect(winner?.exitCode).toBe(null) + expect( + yield* Effect.promise(() => + fetch(new URL("/api/health", info.url), { + headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) }, + }).then((response) => response.json()), + ), + ).toEqual({ + healthy: true, + version: info.version, + pid: info.pid, + }) + }).pipe(Effect.scoped), + ) const blockedTemp = registration + "." + info.id + ".tmp" await fs.mkdir(blockedTemp) await fs.rm(registration) @@ -172,6 +181,7 @@ test("concurrent service processes elect one server", async () => { Bun.sleep(10_000).then(() => false), ]) expect(contenderExited).toBe(true) + expect(contender.exitCode).toBe(0) expect((await waitForInfo(registration)).id).toBe(info.id) } finally { contender.kill("SIGTERM")