Skip to content

Commit d6a4933

Browse files
committed
fix(cli): elect one managed daemon
1 parent e1abf59 commit d6a4933

4 files changed

Lines changed: 154 additions & 25 deletions

File tree

packages/cli/src/server-process.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
77
import { Global } from "@opencode-ai/core/global"
88
import { InstallationVersion } from "@opencode-ai/core/installation/version"
99
import { AppProcess } from "@opencode-ai/core/process"
10+
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
1011
import { start } from "@opencode-ai/server/process"
1112
import { randomBytes, randomUUID } from "node:crypto"
1213
import path from "node:path"
@@ -24,10 +25,13 @@ export type Options = {
2425
readonly port?: number
2526
}
2627

28+
type ManagedServiceOptions = Service.Options & { readonly file: string }
29+
2730
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
2831
processEffect(options).pipe(
32+
Effect.catchTag("ServiceAlreadyOwned", () => Effect.void),
2933
Effect.provide(Updater.layer),
30-
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
34+
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))),
3135
Effect.provide(NodeServices.layer),
3236
),
3337
)
@@ -43,6 +47,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
4347
delete process.env.OPENCODE_SERVER_PASSWORD
4448
}
4549
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
50+
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
51+
if (serviceOptions) {
52+
const flock = yield* EffectFlock.Service
53+
yield* flock.tryAcquire(`opencode-service:${serviceOptions.file}`).pipe(
54+
Effect.filterOrFail(
55+
(acquired) => acquired,
56+
() => ({ _tag: "ServiceAlreadyOwned" }) as const,
57+
),
58+
Effect.retry(Schedule.spaced("100 millis").pipe(Schedule.both(Schedule.recurs(20)))),
59+
)
60+
}
4661
const password =
4762
options.mode === "service"
4863
? yield* ServiceConfig.password()
@@ -55,7 +70,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
5570
port: Option.fromNullishOr(options.port ?? config.port),
5671
password,
5772
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
58-
if (options.mode === "service") yield* register(address, password)
73+
if (serviceOptions) yield* register(address, password, serviceOptions)
5974
const url = HttpServer.formatAddress(address)
6075
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
6176
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
@@ -72,9 +87,12 @@ const infoJson = Schema.fromJsonString(Service.Info)
7287
const encodeInfo = Schema.encodeEffect(infoJson)
7388
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
7489

75-
const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
90+
const register = Effect.fnUntraced(function* (
91+
address: HttpServer.Address,
92+
password: string,
93+
options: ManagedServiceOptions,
94+
) {
7695
const fs = yield* FileSystem.FileSystem
77-
const options = yield* ServiceConfig.options()
7896
const id = randomUUID()
7997
const temp = options.file + "." + id + ".tmp"
8098
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
@@ -98,7 +116,7 @@ const register = Effect.fnUntraced(function* (address: HttpServer.Address, passw
98116
? Effect.void
99117
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
100118
),
101-
Effect.repeat(Schedule.spaced("10 seconds")),
119+
Effect.repeat(Schedule.spaced("1 second")),
102120
Effect.forkScoped,
103121
)
104122
yield* Effect.addFinalizer(() =>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect, test } from "bun:test"
2+
import { spawn } from "node:child_process"
3+
import fs from "node:fs/promises"
4+
import os from "node:os"
5+
import path from "node:path"
6+
7+
test("concurrent service candidates elect one owner before serving", async () => {
8+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
9+
const env = {
10+
...process.env,
11+
XDG_CACHE_HOME: path.join(root, "cache"),
12+
XDG_CONFIG_HOME: path.join(root, "config"),
13+
XDG_DATA_HOME: path.join(root, "data"),
14+
XDG_STATE_HOME: path.join(root, "state"),
15+
OPENCODE_DISABLE_AUTOUPDATE: "1",
16+
}
17+
const entry = path.join(import.meta.dir, "../src/index.ts")
18+
const candidates = [
19+
spawn(process.execPath, [entry, "serve", "--service"], { cwd: path.join(import.meta.dir, ".."), env }),
20+
spawn(process.execPath, [entry, "serve", "--service"], { cwd: path.join(import.meta.dir, ".."), env }),
21+
]
22+
23+
try {
24+
const registration = path.join(root, "state", "opencode", "service-local.json")
25+
await waitForFile(registration)
26+
const info = await Bun.file(registration).json()
27+
await waitFor(() => candidates.some((candidate) => candidate.exitCode !== null))
28+
29+
expect(candidates.filter((candidate) => candidate.exitCode === null)).toHaveLength(1)
30+
expect(candidates.find((candidate) => candidate.exitCode !== null)?.exitCode).toBe(0)
31+
expect(candidates.find((candidate) => candidate.exitCode === null)?.pid).toBe(info.pid)
32+
expect(
33+
await fetch(new URL("/api/health", info.url), {
34+
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
35+
}).then((response) => response.ok),
36+
).toBe(true)
37+
} finally {
38+
await Promise.all(candidates.map(stop))
39+
await fs.rm(root, { recursive: true, force: true })
40+
}
41+
}, 20_000)
42+
43+
async function waitForFile(file: string) {
44+
await waitFor(() => Bun.file(file).exists())
45+
}
46+
47+
async function waitFor(check: () => boolean | Promise<boolean>) {
48+
const timeout = Date.now() + 10_000
49+
while (Date.now() < timeout) {
50+
if (await check()) return
51+
await Bun.sleep(20)
52+
}
53+
throw new Error("Timed out waiting for service election")
54+
}
55+
56+
async function stop(process: ReturnType<typeof spawn>) {
57+
if (process.exitCode !== null || process.signalCode !== null) return
58+
const closed = new Promise<void>((resolve) => process.once("close", () => resolve()))
59+
process.kill("SIGTERM")
60+
await closed
61+
}

packages/core/src/util/effect-flock.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export namespace EffectFlock {
6666
)
6767

6868
const decodeMeta = Schema.decodeUnknownSync(LockMetaJson)
69+
const decodeMetaOption = Schema.decodeUnknownOption(LockMetaJson)
6970
const encodeMeta = Schema.encodeSync(LockMetaJson)
7071

7172
// ---------------------------------------------------------------------------
@@ -74,6 +75,7 @@ export namespace EffectFlock {
7475

7576
export interface Interface {
7677
readonly acquire: (key: string, dir?: string) => Effect.Effect<void, LockError, Scope.Scope>
78+
readonly tryAcquire: (key: string, dir?: string) => Effect.Effect<boolean, LockError, Scope.Scope>
7779
readonly withLock: {
7880
(key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R>
7981
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R>
@@ -150,6 +152,26 @@ export namespace EffectFlock {
150152
const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) {
151153
const now = wall()
152154

155+
const raw = yield* fs.readFileString(metaPath).pipe(
156+
Effect.map(Option.some),
157+
Effect.catchIf(isPathGone, () => Effect.succeed(Option.none())),
158+
Effect.orDie,
159+
)
160+
const owner = Option.isSome(raw) ? Option.getOrUndefined(decodeMetaOption(raw.value)) : undefined
161+
if (owner?.hostname === hostname) {
162+
const alive = yield* Effect.try({
163+
try: () => {
164+
process.kill(owner.pid, 0)
165+
return true
166+
},
167+
catch: (cause) => {
168+
const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined
169+
return code !== "ESRCH"
170+
},
171+
}).pipe(Effect.orElseSucceed(() => false))
172+
if (!alive) return true
173+
}
174+
153175
const hb = yield* safeStat(heartbeatPath)
154176
if (hb) return now - mtimeMs(hb) > STALE_MS
155177

@@ -243,26 +265,44 @@ export namespace EffectFlock {
243265
catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }),
244266
}).pipe(Effect.orDie)
245267

246-
if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" }))
268+
if (parsed.token !== handle.token) yield* Effect.die(new ReleaseError({ detail: "token mismatch" }))
247269

248270
yield* forceRemove(handle.lockDir)
249271
})
250272

251273
// -- build service --
252274

275+
const heartbeat = Effect.fnUntraced(function* (handle: Handle) {
276+
yield* fs
277+
.utimes(handle.heartbeatPath, new Date(), new Date())
278+
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
279+
})
280+
253281
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) {
254282
const lockDir = dir ?? lockRoot
255283
yield* ensureDir(lockDir)
284+
const handle = yield* Effect.acquireRelease(
285+
acquireHandle(path.join(lockDir, Hash.fast(key) + ".lock"), key),
286+
(handle) => release(handle),
287+
)
288+
yield* heartbeat(handle)
289+
})
256290

257-
const lockfile = path.join(lockDir, Hash.fast(key) + ".lock")
258-
259-
// acquireRelease: acquire is uninterruptible, release is guaranteed
260-
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle))
261-
262-
// Heartbeat fiber — scoped, so it's interrupted before release runs
263-
yield* fs
264-
.utimes(handle.heartbeatPath, new Date(), new Date())
265-
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
291+
const tryAcquire = Effect.fn("EffectFlock.tryAcquire")(function* (key: string, dir?: string) {
292+
return yield* Effect.uninterruptibleMask(() =>
293+
Effect.gen(function* () {
294+
const lockDir = dir ?? lockRoot
295+
yield* ensureDir(lockDir)
296+
const handle = yield* tryAcquireLockDir(path.join(lockDir, Hash.fast(key) + ".lock"), key).pipe(
297+
Effect.map(Option.some),
298+
Effect.catchTag("NotAcquired", () => Effect.succeed(Option.none())),
299+
)
300+
if (Option.isNone(handle)) return false
301+
yield* Effect.addFinalizer(() => release(handle.value))
302+
yield* heartbeat(handle.value)
303+
return true
304+
}),
305+
)
266306
})
267307

268308
const withLock: Interface["withLock"] = Function.dual(
@@ -276,7 +316,7 @@ export namespace EffectFlock {
276316
),
277317
)
278318

279-
return Service.of({ acquire, withLock })
319+
return Service.of({ acquire, tryAcquire, withLock })
280320
}),
281321
)
282322

packages/core/test/util/effect-flock.test.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import os from "os"
66
import { Cause, Effect, Exit } from "effect"
77
import { testEffect } from "../lib/effect"
88
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
9-
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
109
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
1110
import { Global } from "@opencode-ai/core/global"
1211
import { Hash } from "@opencode-ai/core/util/hash"
@@ -134,6 +133,24 @@ describe("util.effect-flock", () => {
134133
}),
135134
)
136135

136+
it.live(
137+
"tries once without waiting for an active owner",
138+
Effect.gen(function* () {
139+
const flock = yield* EffectFlock.Service
140+
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
141+
const dir = path.join(tmp, "locks")
142+
143+
yield* Effect.scoped(
144+
Effect.gen(function* () {
145+
expect(yield* flock.tryAcquire("eflock:try", dir)).toBe(true)
146+
expect(yield* Effect.scoped(flock.tryAcquire("eflock:try", dir))).toBe(false)
147+
}),
148+
)
149+
expect(yield* Effect.scoped(flock.tryAcquire("eflock:try", dir))).toBe(true)
150+
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
151+
}),
152+
)
153+
137154
it.live(
138155
"withLock data-first",
139156
Effect.gen(function* () {
@@ -358,7 +375,7 @@ describe("util.effect-flock", () => {
358375
)
359376

360377
it.live(
361-
"recovers after a crashed lock owner",
378+
"immediately recovers after a local lock owner crashes",
362379
() =>
363380
Effect.promise(async () => {
364381
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-"))
@@ -371,13 +388,6 @@ describe("util.effect-flock", () => {
371388
await waitForFile(ready, 5_000)
372389
await stopWorker(proc)
373390

374-
// Backdate lock files so they're past STALE_MS (60s)
375-
const lockDir = lock(dir, "eflock:crash")
376-
const old = new Date(Date.now() - 120_000)
377-
await fs.utimes(lockDir, old, old).catch(() => {})
378-
await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {})
379-
await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
380-
381391
const done = path.join(tmp, "done.log")
382392
const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
383393
expect(result.code).toBe(0)

0 commit comments

Comments
 (0)