-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathinstance-lock.ts
More file actions
206 lines (195 loc) · 6.69 KB
/
Copy pathinstance-lock.ts
File metadata and controls
206 lines (195 loc) · 6.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
/**
* InstanceLock: single-leader election between plugin instances that share one
* tasks.json. opencode can load the same plugin through two case-variant paths
* (macOS case-insensitive FS), and every `opencode run --attach` spawns another
* in-process instance — each with its own ticker. Without coordination every
* instance fires the same due task (B1: duplicate fires + lost writes).
*
* Design:
* - The lock is a DIRECTORY ({storageDir}/loop.lock/) so acquisition is an
* atomic mkdirSync. Inside it, lock.json records the owner.
* - Same-process instances share a pid, so ownership is keyed by a random
* instanceId, not by pid.
* - The leader heartbeats by touching lock.json every heartbeatMs. A follower
* takes over only when the lock is stale (no heartbeat for staleMs) and it
* wins an atomic rename race.
* - Followers keep their ticker running but skip firing; commands and tool
* calls still work because every store write goes through merge-write.
*
* Implementation note: factory pattern (no `this` reliance) so opencode's
* plugin loader can call us with or without `new`.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"
import { hostname } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { errorMessage, type LoopLogger } from "./runtime-feedback.js"
export interface InstanceLockOptions {
storageDir: string
/** Injectable for tests; defaults to a random UUID. */
instanceId?: string
/** Lock without a heartbeat for this long is considered abandoned (default 15_000). */
staleMs?: number
/** Heartbeat / takeover-probe interval (default 2_500). */
heartbeatMs?: number
logger?: LoopLogger
/** Injectable clock for tests. */
now?: () => number
}
export interface InstanceLockInstance {
instanceId: string
isLeader(): boolean
/**
* Whether this instance's ticker may fire tasks. The lock only serializes
* instances inside ONE process (case-variant plugin paths); when the lock
* is held by a DIFFERENT process, that process fires its own tasks and we
* fire ours — so anything but "same-pid foreign leader" allows firing.
*/
shouldFire(): boolean
/** Begin heartbeat / takeover probing. Safe to call once. */
start(): void
/** Stop probing; release the lock if leader. */
stop(): void
}
interface LockFile {
instanceId: string
pid: number
hostname: string
startedAt: number
}
export function InstanceLock(this: unknown, options: InstanceLockOptions): InstanceLockInstance {
void this
const logger: LoopLogger = options.logger ?? (async () => {})
const now = options.now ?? Date.now
const instanceId = options.instanceId ?? randomUUID()
const staleMs = options.staleMs ?? 15_000
const heartbeatMs = options.heartbeatMs ?? 2_500
const lockDir = join(options.storageDir, "loop.lock")
const lockFile = join(lockDir, "lock.json")
let leading = false
let ownerPid: number | null = null
let timer: ReturnType<typeof setInterval> | null = null
const writeLockFile = () => {
const body: LockFile = {
instanceId,
pid: process.pid,
hostname: hostname(),
startedAt: now(),
}
writeFileSync(lockFile, JSON.stringify(body, null, 2), "utf-8")
}
const readLockMtime = (): number | null => {
try {
return statSync(lockFile).mtimeMs
} catch {
return null
}
}
const acquire = (): boolean => {
try {
mkdirSync(lockDir)
writeLockFile()
ownerPid = process.pid
return true
} catch {
return false
}
}
const tryTakeover = async (): Promise<boolean> => {
if (acquire()) {
await logger("info", "loop instance lock acquired", { instanceId })
return true
}
// Lock held: am I the owner? (e.g. after a same-process reload)
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
ownerPid = owner.pid
if (owner.instanceId === instanceId) return true
} catch {
// Unreadable lock file: fall through to staleness check
ownerPid = null
}
const mtime = readLockMtime()
const stale = mtime === null || now() - mtime > staleMs
if (!stale) return false
// Abandoned lock: win an atomic rename race before deleting it, so two
// followers cannot both take over.
const graveyard = `${lockDir}.stale.${instanceId}`
try {
renameSync(lockDir, graveyard)
} catch {
return false
}
try {
rmSync(graveyard, { recursive: true, force: true })
} catch {
// Non-fatal: a stale graveyard directory does not block acquisition.
}
const won = acquire()
if (won) {
await logger("info", "loop instance lock taken over from stale owner", { instanceId })
}
return won
}
const tick = async () => {
try {
if (leading) {
// Still mine? A same-process follower may have taken over after
// deciding our heartbeat stopped (e.g. event-loop stall).
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
ownerPid = owner.pid
if (owner.instanceId !== instanceId) {
leading = false
await logger("warn", "loop instance lock lost", { instanceId })
return
}
} catch {
leading = false
ownerPid = null
await logger("warn", "loop instance lock lost (unreadable)", { instanceId })
return
}
try {
const at = new Date(now())
utimesSync(lockFile, at, at)
} catch (err) {
await logger("warn", "loop lock heartbeat failed", { error: errorMessage(err) })
}
return
}
leading = await tryTakeover()
} catch (err) {
await logger("warn", "loop instance lock tick failed", { error: errorMessage(err) })
}
}
const inst: InstanceLockInstance = {
instanceId,
isLeader: () => leading,
shouldFire: () => leading || ownerPid !== process.pid,
start: () => {
if (timer) return
void tick()
timer = setInterval(() => void tick(), heartbeatMs)
// Never keep the process alive just for the lock.
if (typeof timer.unref === "function") timer.unref()
},
stop: () => {
if (timer) {
clearInterval(timer)
timer = null
}
if (leading) {
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
if (owner.instanceId === instanceId) rmSync(lockDir, { recursive: true, force: true })
} catch {
// Lock already gone or unreadable — nothing to release.
}
leading = false
}
ownerPid = null
},
}
return inst
}