fix(cli): elect one managed daemon
This commit is contained in:
parent
8b634e4a58
commit
68c62774ac
4 changed files with 154 additions and 25 deletions
|
|
@ -66,6 +66,7 @@ export namespace EffectFlock {
|
|||
)
|
||||
|
||||
const decodeMeta = Schema.decodeUnknownSync(LockMetaJson)
|
||||
const decodeMetaOption = Schema.decodeUnknownOption(LockMetaJson)
|
||||
const encodeMeta = Schema.encodeSync(LockMetaJson)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -74,6 +75,7 @@ export namespace EffectFlock {
|
|||
|
||||
export interface Interface {
|
||||
readonly acquire: (key: string, dir?: string) => Effect.Effect<void, LockError, Scope.Scope>
|
||||
readonly tryAcquire: (key: string, dir?: string) => Effect.Effect<boolean, LockError, Scope.Scope>
|
||||
readonly withLock: {
|
||||
(key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R>
|
||||
<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 {
|
|||
const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) {
|
||||
const now = wall()
|
||||
|
||||
const raw = yield* fs.readFileString(metaPath).pipe(
|
||||
Effect.map(Option.some),
|
||||
Effect.catchIf(isPathGone, () => Effect.succeed(Option.none())),
|
||||
Effect.orDie,
|
||||
)
|
||||
const owner = Option.isSome(raw) ? Option.getOrUndefined(decodeMetaOption(raw.value)) : undefined
|
||||
if (owner?.hostname === hostname) {
|
||||
const alive = yield* Effect.try({
|
||||
try: () => {
|
||||
process.kill(owner.pid, 0)
|
||||
return true
|
||||
},
|
||||
catch: (cause) => {
|
||||
const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined
|
||||
return code !== "ESRCH"
|
||||
},
|
||||
}).pipe(Effect.orElseSucceed(() => false))
|
||||
if (!alive) return true
|
||||
}
|
||||
|
||||
const hb = yield* safeStat(heartbeatPath)
|
||||
if (hb) return now - mtimeMs(hb) > STALE_MS
|
||||
|
||||
|
|
@ -243,28 +265,46 @@ export namespace EffectFlock {
|
|||
catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }),
|
||||
}).pipe(Effect.orDie)
|
||||
|
||||
if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" }))
|
||||
if (parsed.token !== handle.token) yield* Effect.die(new ReleaseError({ detail: "token mismatch" }))
|
||||
|
||||
yield* forceRemove(handle.lockDir)
|
||||
})
|
||||
|
||||
// -- build service --
|
||||
|
||||
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) {
|
||||
const lockDir = dir ?? lockRoot
|
||||
yield* ensureDir(lockDir)
|
||||
|
||||
const lockfile = path.join(lockDir, Hash.fast(key) + ".lock")
|
||||
|
||||
// acquireRelease: acquire is uninterruptible, release is guaranteed
|
||||
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle))
|
||||
|
||||
// Heartbeat fiber — scoped, so it's interrupted before release runs
|
||||
const heartbeat = Effect.fnUntraced(function* (handle: Handle) {
|
||||
yield* fs
|
||||
.utimes(handle.heartbeatPath, new Date(), new Date())
|
||||
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
|
||||
})
|
||||
|
||||
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) {
|
||||
const lockDir = dir ?? lockRoot
|
||||
yield* ensureDir(lockDir)
|
||||
const handle = yield* Effect.acquireRelease(
|
||||
acquireHandle(path.join(lockDir, Hash.fast(key) + ".lock"), key),
|
||||
(handle) => release(handle),
|
||||
)
|
||||
yield* heartbeat(handle)
|
||||
})
|
||||
|
||||
const tryAcquire = Effect.fn("EffectFlock.tryAcquire")(function* (key: string, dir?: string) {
|
||||
return yield* Effect.uninterruptibleMask(() =>
|
||||
Effect.gen(function* () {
|
||||
const lockDir = dir ?? lockRoot
|
||||
yield* ensureDir(lockDir)
|
||||
const handle = yield* tryAcquireLockDir(path.join(lockDir, Hash.fast(key) + ".lock"), key).pipe(
|
||||
Effect.map(Option.some),
|
||||
Effect.catchTag("NotAcquired", () => Effect.succeed(Option.none())),
|
||||
)
|
||||
if (Option.isNone(handle)) return false
|
||||
yield* Effect.addFinalizer(() => release(handle.value))
|
||||
yield* heartbeat(handle.value)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const withLock: Interface["withLock"] = Function.dual(
|
||||
(args) => Effect.isEffect(args[0]),
|
||||
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R> =>
|
||||
|
|
@ -276,7 +316,7 @@ export namespace EffectFlock {
|
|||
),
|
||||
)
|
||||
|
||||
return Service.of({ acquire, withLock })
|
||||
return Service.of({ acquire, tryAcquire, withLock })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import os from "os"
|
|||
import { Cause, Effect, Exit } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
|
|
@ -134,6 +133,24 @@ describe("util.effect-flock", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"tries once without waiting for an active owner",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const dir = path.join(tmp, "locks")
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
expect(yield* flock.tryAcquire("eflock:try", dir)).toBe(true)
|
||||
expect(yield* Effect.scoped(flock.tryAcquire("eflock:try", dir))).toBe(false)
|
||||
}),
|
||||
)
|
||||
expect(yield* Effect.scoped(flock.tryAcquire("eflock:try", dir))).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"withLock data-first",
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -358,7 +375,7 @@ describe("util.effect-flock", () => {
|
|||
)
|
||||
|
||||
it.live(
|
||||
"recovers after a crashed lock owner",
|
||||
"immediately recovers after a local lock owner crashes",
|
||||
() =>
|
||||
Effect.promise(async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-"))
|
||||
|
|
@ -371,13 +388,6 @@ describe("util.effect-flock", () => {
|
|||
await waitForFile(ready, 5_000)
|
||||
await stopWorker(proc)
|
||||
|
||||
// Backdate lock files so they're past STALE_MS (60s)
|
||||
const lockDir = lock(dir, "eflock:crash")
|
||||
const old = new Date(Date.now() - 120_000)
|
||||
await fs.utimes(lockDir, old, old).catch(() => {})
|
||||
await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {})
|
||||
await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
|
||||
|
||||
const done = path.join(tmp, "done.log")
|
||||
const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
|
||||
expect(result.code).toBe(0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue