weir-harness 1.2.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +7 -18
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
// The client half of the edit-lock lease protocol (design D6, D7, D8).
|
|
2
|
+
//
|
|
3
|
+
// The daemon in ../lease-broker/ is the ONLY arbiter: every verdict about who
|
|
4
|
+
// may write a file arrives over this socket, and nothing here re-derives one.
|
|
5
|
+
// What lives in this file:
|
|
6
|
+
//
|
|
7
|
+
// - one lazily established connection per client, one `hello` handshake per
|
|
8
|
+
// connection, one `reqId` per request (replies are matched by that id and by
|
|
9
|
+
// nothing else);
|
|
10
|
+
// - the in-memory lease ledger (`path -> {session, lineage, holdUntil}`). It is
|
|
11
|
+
// used for exactly two things — knowing what a turn boundary must release and
|
|
12
|
+
// what a reconnect must replay. It NEVER decides a conflict (D6): the moment
|
|
13
|
+
// this file starts reasoning about ownership it becomes a second authority,
|
|
14
|
+
// which is the failure mode this whole design exists to delete;
|
|
15
|
+
// - degrade-and-proceed (D7). An unhealthy arbiter never blocks a write; a real
|
|
16
|
+
// `held` conflict always does. The protocol's closed error vocabulary is what
|
|
17
|
+
// keeps those two apart — no heuristics, no "is it probably fine".
|
|
18
|
+
//
|
|
19
|
+
// Deliberately absent: killing or restarting the daemon. The daemon is a cache
|
|
20
|
+
// with an idle window and a stale-socket recovery of its own, and a client that
|
|
21
|
+
// permanently gives up after one transport failure would turn a transient blip
|
|
22
|
+
// into a session-long loss of arbitration. Every later call re-tries the
|
|
23
|
+
// connection (and the spawn); only the audit is rate limited.
|
|
24
|
+
//
|
|
25
|
+
// Every external dependency is injected so a unit test can drive the state
|
|
26
|
+
// machine without a socket, a clock, or a child process.
|
|
27
|
+
|
|
28
|
+
import { isAbsolute } from 'node:path'
|
|
29
|
+
import { createConnection } from 'node:net'
|
|
30
|
+
import { PROTOCOL } from '../lease-broker/protocol.js'
|
|
31
|
+
import { defaultSocketPath } from '../lease-broker/server.js'
|
|
32
|
+
import { spawnDaemon as defaultSpawnDaemon } from './spawn.js'
|
|
33
|
+
|
|
34
|
+
/** Per-request deadline. A slow arbiter is an unhealthy arbiter (D7). */
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 2_000
|
|
36
|
+
|
|
37
|
+
/** Pause between connection attempts while a spawned daemon comes up. */
|
|
38
|
+
const DEFAULT_CONNECT_RETRY_MS = 50
|
|
39
|
+
|
|
40
|
+
/** Total budget for "spawn, then wait for the socket" before degrading. */
|
|
41
|
+
const DEFAULT_CONNECT_RETRY_BUDGET_MS = 1_500
|
|
42
|
+
|
|
43
|
+
/** Retention bound in minutes, mirrored from the protocol for replay math. */
|
|
44
|
+
const HOLD_MAX_MINUTES = 60
|
|
45
|
+
|
|
46
|
+
/** Infrastructure failure: connection, handshake, timeout, write, closed. */
|
|
47
|
+
export const TRANSPORT_CODE = 'transport'
|
|
48
|
+
|
|
49
|
+
/** Real conflict: someone else holds the path. Never degraded (D7). */
|
|
50
|
+
const CONFLICT_CODE = 'held'
|
|
51
|
+
|
|
52
|
+
/** Endpoint-absent errors: nobody is listening yet, so spawning may help. */
|
|
53
|
+
const ABSENT_ENDPOINT_CODES = new Set(['ENOENT', 'ECONNREFUSED'])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Degraded-mode audit throttle: at most one record per process per window (D7).
|
|
57
|
+
* Exported because the operator-facing contract names the number.
|
|
58
|
+
*/
|
|
59
|
+
export const DEGRADED_AUDIT_WINDOW_MS = 60_000
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Error & {
|
|
63
|
+
* code: string,
|
|
64
|
+
* conflicts?: any[],
|
|
65
|
+
* paths?: string[],
|
|
66
|
+
* maxMinutes?: number,
|
|
67
|
+
* cause?: unknown,
|
|
68
|
+
* }} LeaseFailure
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} LeaseRecord
|
|
73
|
+
* @property {string} session the session that last touched the lease (release scope)
|
|
74
|
+
* @property {string} lineage the opaque `owner.id` this client acquired under
|
|
75
|
+
* @property {number | null} holdUntil retention deadline in epoch ms, `null` when unretained
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The transport contract the state machine speaks. `connect()` may resolve a
|
|
80
|
+
* connection asynchronously and must reject with the underlying error (so
|
|
81
|
+
* `ENOENT`/`ECONNREFUSED` can be recognised as "daemon not up yet").
|
|
82
|
+
*
|
|
83
|
+
* @typedef {object} LeaseConnection
|
|
84
|
+
* @property {(text: string) => void} write may throw; every throw is a transport failure
|
|
85
|
+
* @property {(handler: (chunk: string) => void) => void} onData newline-delimited frames
|
|
86
|
+
* @property {(handler: () => void) => void} onClose called once when the connection is gone
|
|
87
|
+
* @property {() => void} close idempotent
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {object} LeaseClientOptions
|
|
92
|
+
* @property {string} [socketPath] endpoint address; defaults to the daemon's own `defaultSocketPath({protocol})`
|
|
93
|
+
* @property {number} [protocol] protocol major sent in the handshake
|
|
94
|
+
* @property {number} [timeoutMs] per-request deadline
|
|
95
|
+
* @property {() => number} [now] injected clock (epoch ms)
|
|
96
|
+
* @property {(socketPath: string) => LeaseConnection | Promise<LeaseConnection>} [connect] dial the endpoint
|
|
97
|
+
* @property {(() => unknown) | null} [spawnDaemon] called at most once per connection attempt; `null` disables spawning
|
|
98
|
+
* @property {(kind: string, detail: Record<string, any>) => void} [audit] audit sink (`kind` is `'degraded'`)
|
|
99
|
+
* @property {number} [connectRetryMs] pause between connection attempts after a spawn
|
|
100
|
+
* @property {number} [connectRetryBudgetMs] total budget for connect attempts after a spawn
|
|
101
|
+
* @property {number} [degradedAuditWindowMs] throttle window for the degraded audit record
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} code
|
|
106
|
+
* @param {string} message
|
|
107
|
+
* @param {{ conflicts?: any[], paths?: string[], maxMinutes?: number, cause?: unknown }} [detail]
|
|
108
|
+
* @returns {LeaseFailure}
|
|
109
|
+
*/
|
|
110
|
+
function leaseError(code, message, detail = {}) {
|
|
111
|
+
/** @type {LeaseFailure} */
|
|
112
|
+
const error = /** @type {LeaseFailure} */ (new Error(message))
|
|
113
|
+
error.code = code
|
|
114
|
+
if (Array.isArray(detail.conflicts)) error.conflicts = detail.conflicts
|
|
115
|
+
if (Array.isArray(detail.paths)) error.paths = detail.paths
|
|
116
|
+
if (typeof detail.maxMinutes === 'number') error.maxMinutes = detail.maxMinutes
|
|
117
|
+
if (detail.cause !== undefined) error.cause = detail.cause
|
|
118
|
+
return error
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} message
|
|
123
|
+
* @param {unknown} [cause]
|
|
124
|
+
* @returns {LeaseFailure}
|
|
125
|
+
*/
|
|
126
|
+
function transportError(message, cause) {
|
|
127
|
+
return leaseError(TRANSPORT_CODE, message, { cause })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True for a real conflict — the only refusal that may stop a write.
|
|
132
|
+
* @param {unknown} error
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function isLeaseConflict(error) {
|
|
136
|
+
return !!error && typeof error === 'object' && /** @type {any} */ (error).code === CONFLICT_CODE
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {unknown} error
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function errorCode(error) {
|
|
144
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
|
|
145
|
+
return typeof code === 'string' ? code : 'internal'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {number} ms
|
|
150
|
+
* @returns {Promise<void>}
|
|
151
|
+
*/
|
|
152
|
+
function delay(ms) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
setTimeout(resolve, ms)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Default dialer: a plain newline-JSON socket from `node:net`.
|
|
160
|
+
* @param {string} socketPath
|
|
161
|
+
* @returns {Promise<LeaseConnection>}
|
|
162
|
+
*/
|
|
163
|
+
function defaultConnect(socketPath) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const socket = createConnection({ path: socketPath })
|
|
166
|
+
socket.setEncoding('utf8')
|
|
167
|
+
let connected = false
|
|
168
|
+
let settled = false
|
|
169
|
+
socket.once('connect', () => {
|
|
170
|
+
connected = true
|
|
171
|
+
settled = true
|
|
172
|
+
socket.setNoDelay(true)
|
|
173
|
+
// A lease connection is process state (design D11), not a pending task: it
|
|
174
|
+
// must never keep an otherwise-finished Node process alive. Without this
|
|
175
|
+
// an idle client pins the event loop, so a headless CLI turn that ended
|
|
176
|
+
// normally only dies when its caller times out (observed as a hung
|
|
177
|
+
// integration boot), and a graceful host shutdown would wait forever.
|
|
178
|
+
// `unref` is part of Node's net.Socket but not modelled by the hand-written
|
|
179
|
+
// node contracts this bundle compiles against (`types: []`), and the
|
|
180
|
+
// connection is an injectable stub in tests — hence the guarded call.
|
|
181
|
+
const timer = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (socket))
|
|
182
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
183
|
+
resolve(wrapSocket(socket))
|
|
184
|
+
})
|
|
185
|
+
socket.once('error', (error) => {
|
|
186
|
+
// Pre-connect: the dial failed and the caller decides whether to spawn and
|
|
187
|
+
// re-try. Post-connect: the wrapper's own listener turns it into a close.
|
|
188
|
+
if (connected || settled) return
|
|
189
|
+
settled = true
|
|
190
|
+
try {
|
|
191
|
+
socket.destroy()
|
|
192
|
+
} catch {
|
|
193
|
+
// Already gone.
|
|
194
|
+
}
|
|
195
|
+
reject(error)
|
|
196
|
+
})
|
|
197
|
+
socket.on('close', () => {
|
|
198
|
+
if (connected || settled) return
|
|
199
|
+
settled = true
|
|
200
|
+
reject(leaseError(TRANSPORT_CODE, `lease broker closed the connection during dial to ${socketPath}`))
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Narrow a Node socket to the `LeaseConnection` contract, making the close
|
|
207
|
+
* notification exactly-once (Node may emit both `error` and `close`).
|
|
208
|
+
* @param {import('node:net').Socket} socket
|
|
209
|
+
* @returns {LeaseConnection}
|
|
210
|
+
*/
|
|
211
|
+
function wrapSocket(socket) {
|
|
212
|
+
let released = false
|
|
213
|
+
/** @type {(() => void)[]} */
|
|
214
|
+
const listeners = []
|
|
215
|
+
const notify = () => {
|
|
216
|
+
if (released) return
|
|
217
|
+
released = true
|
|
218
|
+
for (const listener of listeners.splice(0)) {
|
|
219
|
+
try {
|
|
220
|
+
listener()
|
|
221
|
+
} catch {
|
|
222
|
+
// A transport listener must never break the client's own bookkeeping.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
socket.on('error', () => {
|
|
227
|
+
try {
|
|
228
|
+
socket.destroy()
|
|
229
|
+
} catch {
|
|
230
|
+
// Already gone.
|
|
231
|
+
}
|
|
232
|
+
notify()
|
|
233
|
+
})
|
|
234
|
+
socket.on('close', notify)
|
|
235
|
+
return {
|
|
236
|
+
write(text) {
|
|
237
|
+
socket.write(text)
|
|
238
|
+
},
|
|
239
|
+
onData(handler) {
|
|
240
|
+
socket.on('data', handler)
|
|
241
|
+
},
|
|
242
|
+
onClose(handler) {
|
|
243
|
+
if (released) {
|
|
244
|
+
// Registered too late: report asynchronously so the caller's state
|
|
245
|
+
// machine cannot re-enter mid-registration.
|
|
246
|
+
queueMicrotask(() => {
|
|
247
|
+
try {
|
|
248
|
+
handler()
|
|
249
|
+
} catch {
|
|
250
|
+
// Same rule as notify().
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
listeners.push(handler)
|
|
256
|
+
},
|
|
257
|
+
close() {
|
|
258
|
+
notify()
|
|
259
|
+
try {
|
|
260
|
+
socket.destroy()
|
|
261
|
+
} catch {
|
|
262
|
+
// Already gone.
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Create one lease client.
|
|
270
|
+
*
|
|
271
|
+
* A process shares ONE client (design D8/D11): one connection carries every
|
|
272
|
+
* lease of this process, so two preset mounts must never each create their own.
|
|
273
|
+
* The factory exists so tests and the wiring can build independent instances.
|
|
274
|
+
*
|
|
275
|
+
* @param {LeaseClientOptions} [options]
|
|
276
|
+
*/
|
|
277
|
+
export function createLeaseClient({
|
|
278
|
+
socketPath,
|
|
279
|
+
protocol = PROTOCOL,
|
|
280
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
281
|
+
now = Date.now,
|
|
282
|
+
connect = defaultConnect,
|
|
283
|
+
spawnDaemon = defaultSpawnDaemon,
|
|
284
|
+
audit = () => {},
|
|
285
|
+
connectRetryMs = DEFAULT_CONNECT_RETRY_MS,
|
|
286
|
+
connectRetryBudgetMs = DEFAULT_CONNECT_RETRY_BUDGET_MS,
|
|
287
|
+
degradedAuditWindowMs = DEGRADED_AUDIT_WINDOW_MS,
|
|
288
|
+
} = {}) {
|
|
289
|
+
const endpoint = typeof socketPath === 'string' && socketPath.length > 0 ? socketPath : defaultSocketPath({ protocol })
|
|
290
|
+
|
|
291
|
+
/** @type {LeaseConnection | null} */
|
|
292
|
+
let connection = null
|
|
293
|
+
let greeted = false
|
|
294
|
+
/** @type {Promise<LeaseConnection> | null} */
|
|
295
|
+
let connecting = null
|
|
296
|
+
let closed = false
|
|
297
|
+
/**
|
|
298
|
+
* Waits that a close answers instead of the dial. `connect` has no abort
|
|
299
|
+
* contract — an injected one may never settle at all — so a close must
|
|
300
|
+
* release these itself; nothing here waits for a socket (D5).
|
|
301
|
+
* @type {Set<() => void>}
|
|
302
|
+
*/
|
|
303
|
+
const closeWaiters = new Set()
|
|
304
|
+
/** @type {number | undefined} */
|
|
305
|
+
let startedAt
|
|
306
|
+
/** A connection reset means the daemon may have restarted with an empty table (D6). */
|
|
307
|
+
let replayPending = false
|
|
308
|
+
/** A new incarnation was detected; the next request (or `replayHolds`) replays first. */
|
|
309
|
+
let replayQueued = false
|
|
310
|
+
/** @type {Promise<{ replayed: number, dropped: number }> | null} */
|
|
311
|
+
let replaying = null
|
|
312
|
+
/** @type {number | null} */
|
|
313
|
+
let lastDegradedAt = null
|
|
314
|
+
/** @type {string | undefined} */
|
|
315
|
+
let lastScope
|
|
316
|
+
/**
|
|
317
|
+
* The push events the CURRENT handshake advertised. Empty until a handshake
|
|
318
|
+
* answers and after the connection is gone, so "not advertised" and "not
|
|
319
|
+
* connected" both read as unsupported: a caller must never wait for an event
|
|
320
|
+
* this incarnation cannot send.
|
|
321
|
+
* @type {Set<string>}
|
|
322
|
+
*/
|
|
323
|
+
let eventCapabilities = new Set()
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The replay ledger. `path -> {session, lineage, holdUntil}` — nothing more.
|
|
327
|
+
* In particular there is no `owner`-decides-anything field here, by design.
|
|
328
|
+
* @type {Map<string, LeaseRecord>}
|
|
329
|
+
*/
|
|
330
|
+
const ledger = new Map()
|
|
331
|
+
/** `lineage -> label`, presentation only; the broker never compares it. @type {Map<string, string>} */
|
|
332
|
+
const labels = new Map()
|
|
333
|
+
/** @type {Set<(event: any) => void>} */
|
|
334
|
+
const eventHandlers = new Set()
|
|
335
|
+
/** Connection-reset lifecycle listeners (D5). Separate from the wire events
|
|
336
|
+
* above on purpose: a reset is a property of THIS client's transport, not a
|
|
337
|
+
* fact the broker can send. @type {Set<() => void>} */
|
|
338
|
+
const resetHandlers = new Set()
|
|
339
|
+
/**
|
|
340
|
+
* @type {Map<string, {
|
|
341
|
+
* resolve: (reply: any) => void,
|
|
342
|
+
* reject: (error: any) => void,
|
|
343
|
+
* timer: ReturnType<typeof setTimeout>,
|
|
344
|
+
* }>}
|
|
345
|
+
*/
|
|
346
|
+
const waiters = new Map()
|
|
347
|
+
let requestSeq = 0
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------
|
|
350
|
+
// Transport: connection lifecycle, framing, correlation
|
|
351
|
+
// ---------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/** @returns {string} a correlation token unique to this client */
|
|
354
|
+
function nextReqId() {
|
|
355
|
+
requestSeq += 1
|
|
356
|
+
return `c${requestSeq}`
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @param {LeaseConnection} target
|
|
361
|
+
* @param {Record<string, any>} frame
|
|
362
|
+
* @returns {Promise<any>}
|
|
363
|
+
*/
|
|
364
|
+
function sendFrame(target, frame) {
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
const reqId = nextReqId()
|
|
367
|
+
const timer = setTimeout(() => {
|
|
368
|
+
waiters.delete(reqId)
|
|
369
|
+
reject(transportError(`lease broker did not answer ${String(frame.op)} within ${timeoutMs}ms`))
|
|
370
|
+
}, timeoutMs)
|
|
371
|
+
waiters.set(reqId, { resolve, reject, timer })
|
|
372
|
+
try {
|
|
373
|
+
// `reqId` is written last so a caller-supplied payload cannot collide.
|
|
374
|
+
target.write(`${JSON.stringify({ ...frame, reqId })}\n`)
|
|
375
|
+
} catch (error) {
|
|
376
|
+
clearTimeout(timer)
|
|
377
|
+
waiters.delete(reqId)
|
|
378
|
+
reject(transportError(`lease broker write failed at ${endpoint}`, error))
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* @param {LeaseConnection} target
|
|
385
|
+
* @param {string} op
|
|
386
|
+
* @param {Record<string, any>} [payload]
|
|
387
|
+
* @returns {Promise<any>}
|
|
388
|
+
*/
|
|
389
|
+
async function exchange(target, op, payload = {}) {
|
|
390
|
+
const reply = await sendFrame(target, { op, ...payload })
|
|
391
|
+
if (reply !== null && typeof reply === 'object' && reply.ok === true) return reply
|
|
392
|
+
const detail = reply !== null && typeof reply === 'object' && reply.error !== null && typeof reply.error === 'object' ? reply.error : {}
|
|
393
|
+
const code = typeof detail.code === 'string' ? detail.code : 'internal'
|
|
394
|
+
throw leaseError(code, `lease broker refused ${op}: ${code}`, detail)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @param {any} frame */
|
|
398
|
+
function dispatchEvent(frame) {
|
|
399
|
+
for (const handler of [...eventHandlers]) {
|
|
400
|
+
try {
|
|
401
|
+
handler(frame)
|
|
402
|
+
} catch {
|
|
403
|
+
// A listener is a consumer; it must never be able to break the transport.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Tell every listener that the current connection is gone.
|
|
410
|
+
*
|
|
411
|
+
* Deliberately separate from wire events: a broker that is SIGKILLed cannot
|
|
412
|
+
* announce that it died, so a client that only listened to `EVENTS` would
|
|
413
|
+
* wait forever. Called exactly once per connection (the socket wrapper makes
|
|
414
|
+
* its close notification once-only) and never for an intentional `close()`.
|
|
415
|
+
*/
|
|
416
|
+
function notifyReset() {
|
|
417
|
+
for (const handler of [...resetHandlers]) {
|
|
418
|
+
try {
|
|
419
|
+
handler()
|
|
420
|
+
} catch {
|
|
421
|
+
// A listener is a consumer; it must never break the transport.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @param {string} line */
|
|
427
|
+
function handleFrame(line) {
|
|
428
|
+
/** @type {any} */
|
|
429
|
+
let frame
|
|
430
|
+
try {
|
|
431
|
+
frame = JSON.parse(line)
|
|
432
|
+
} catch {
|
|
433
|
+
// An undecodable line cannot be matched to anything; the waiter's own
|
|
434
|
+
// deadline is what reports it.
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
if (frame === null || typeof frame !== 'object') return
|
|
438
|
+
if (typeof frame.event === 'string') {
|
|
439
|
+
dispatchEvent(frame)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const reqId = typeof frame.reqId === 'string' ? frame.reqId : undefined
|
|
443
|
+
if (reqId === undefined) return
|
|
444
|
+
const waiter = waiters.get(reqId)
|
|
445
|
+
// Correlation is exact: a reply nobody is waiting for is dropped. The
|
|
446
|
+
// alternative — handing it to the next waiter in line — would let one
|
|
447
|
+
// request satisfy another and silently mis-report ownership.
|
|
448
|
+
if (waiter === undefined) return
|
|
449
|
+
waiters.delete(reqId)
|
|
450
|
+
clearTimeout(waiter.timer)
|
|
451
|
+
waiter.resolve(frame)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @param {any} error
|
|
456
|
+
*/
|
|
457
|
+
function failWaiters(error) {
|
|
458
|
+
for (const [reqId, waiter] of [...waiters]) {
|
|
459
|
+
waiters.delete(reqId)
|
|
460
|
+
clearTimeout(waiter.timer)
|
|
461
|
+
waiter.reject(error)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Answer every wait the close latch owns. A close is terminal for the whole
|
|
467
|
+
* transport lifetime, so a caller parked on a dial is told now rather than
|
|
468
|
+
* inheriting a dial that may never return.
|
|
469
|
+
*/
|
|
470
|
+
function notifyCloseWaiters() {
|
|
471
|
+
for (const waiter of [...closeWaiters]) waiter()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The ONE connection-invalidation outlet (D5).
|
|
476
|
+
*
|
|
477
|
+
* A connection ends in three ways — the socket closed under us, we dropped it
|
|
478
|
+
* to retry, or the client is closing — and all three must leave the same state
|
|
479
|
+
* behind: no current connection, no handshake, no advertised capabilities, no
|
|
480
|
+
* waiter still believing an answer is coming, and exactly one reset
|
|
481
|
+
* notification for the connection that just went away.
|
|
482
|
+
*
|
|
483
|
+
* Only the CURRENT target may do that. A stale target — a handshake that lost
|
|
484
|
+
* a race, or a socket already replaced by a retry — is a no-op: its late close
|
|
485
|
+
* must not clear the live connection's state or notify a second time.
|
|
486
|
+
*
|
|
487
|
+
* @param {LeaseConnection | null} target
|
|
488
|
+
* @param {boolean} [closing] true for an intentional client close: the ledger is
|
|
489
|
+
* already gone, so nothing is queued for replay
|
|
490
|
+
* @returns {boolean} whether this target was the current connection
|
|
491
|
+
*/
|
|
492
|
+
function invalidateConnection(target, closing = false) {
|
|
493
|
+
if (target === null || connection !== target) return false
|
|
494
|
+
connection = null
|
|
495
|
+
greeted = false
|
|
496
|
+
// The advertised capabilities belonged to that connection.
|
|
497
|
+
eventCapabilities = new Set()
|
|
498
|
+
// D6: a reset connection means the daemon may have died and come back with
|
|
499
|
+
// an empty table, so retained leases have to be replayed on the next dial.
|
|
500
|
+
if (!closing && ledger.size > 0) replayPending = true
|
|
501
|
+
failWaiters(transportError(closing ? 'lease client closed' : 'lease broker connection closed'))
|
|
502
|
+
// D5: a reset is a terminal state for anything waiting on this connection,
|
|
503
|
+
// and the only moment a vanished broker can be reported at all.
|
|
504
|
+
notifyReset()
|
|
505
|
+
return true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** @param {LeaseConnection} target */
|
|
509
|
+
function handleDisconnect(target) {
|
|
510
|
+
invalidateConnection(target)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Close a socket without touching client state. Used where the connection is
|
|
515
|
+
* already invalidated (or was never adopted) and only the transport is left.
|
|
516
|
+
* @param {LeaseConnection | null} target
|
|
517
|
+
*/
|
|
518
|
+
function closeSocket(target) {
|
|
519
|
+
if (target === null) return
|
|
520
|
+
try {
|
|
521
|
+
target.close()
|
|
522
|
+
} catch {
|
|
523
|
+
// Already gone.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* @param {LeaseConnection} target
|
|
529
|
+
* @returns {boolean} whether this target was the current connection
|
|
530
|
+
*/
|
|
531
|
+
function dropConnection(target) {
|
|
532
|
+
// Invalidate first: closing the socket reports its close synchronously, and
|
|
533
|
+
// that report must find nothing left to invalidate (exactly-once reset).
|
|
534
|
+
const invalidated = invalidateConnection(target)
|
|
535
|
+
closeSocket(target)
|
|
536
|
+
return invalidated
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Wait for `work`, but never past `close()`.
|
|
541
|
+
*
|
|
542
|
+
* The transport has no abort contract, so a close answers the wait itself:
|
|
543
|
+
* the dial is left to whatever it does, and nobody inherits a promise that
|
|
544
|
+
* may never settle. The waiter is removed as soon as either side answers, and
|
|
545
|
+
* `work` is always consumed — an abandoned dial that fails later can never
|
|
546
|
+
* surface as an unhandled rejection.
|
|
547
|
+
*
|
|
548
|
+
* @template T
|
|
549
|
+
* @param {Promise<T>} work
|
|
550
|
+
* @returns {Promise<T>}
|
|
551
|
+
*/
|
|
552
|
+
function untilClosed(work) {
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
let waiting = true
|
|
555
|
+
/** @param {() => void} settle */
|
|
556
|
+
const finish = (settle) => {
|
|
557
|
+
if (!waiting) return
|
|
558
|
+
waiting = false
|
|
559
|
+
closeWaiters.delete(onClose)
|
|
560
|
+
settle()
|
|
561
|
+
}
|
|
562
|
+
const onClose = () => finish(() => reject(transportError('lease client is closed')))
|
|
563
|
+
closeWaiters.add(onClose)
|
|
564
|
+
// Attach first: a close may already have fired, and `work`'s outcome must
|
|
565
|
+
// still be handled when this wait is answered by the latch instead.
|
|
566
|
+
Promise.resolve(work).then(
|
|
567
|
+
(value) => finish(() => resolve(value)),
|
|
568
|
+
(error) => finish(() => reject(error)),
|
|
569
|
+
)
|
|
570
|
+
if (closed) onClose()
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Dial once, but not past a close.
|
|
576
|
+
*
|
|
577
|
+
* A socket that only arrives after the close is closed on arrival: it can
|
|
578
|
+
* never be adopted, greeted, replayed or used to serve a request, and the
|
|
579
|
+
* caller was already answered by the latch.
|
|
580
|
+
* @returns {Promise<{ connection: LeaseConnection | null, error: any }>}
|
|
581
|
+
*/
|
|
582
|
+
async function attemptConnect() {
|
|
583
|
+
/** @type {LeaseConnection | Promise<LeaseConnection>} */
|
|
584
|
+
let started
|
|
585
|
+
try {
|
|
586
|
+
// Started synchronously: the dial is the caller's, not a later task's.
|
|
587
|
+
started = connect(endpoint)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
return { connection: null, error }
|
|
590
|
+
}
|
|
591
|
+
/** @type {Promise<LeaseConnection>} */
|
|
592
|
+
const dial = Promise.resolve(started).then((connection) => {
|
|
593
|
+
if (closed) {
|
|
594
|
+
closeSocket(connection)
|
|
595
|
+
throw transportError('lease client is closed')
|
|
596
|
+
}
|
|
597
|
+
return connection
|
|
598
|
+
})
|
|
599
|
+
try {
|
|
600
|
+
return { connection: await untilClosed(dial), error: null }
|
|
601
|
+
} catch (error) {
|
|
602
|
+
return { connection: null, error }
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @param {unknown} error @returns {boolean} */
|
|
607
|
+
function isAbsentEndpoint(error) {
|
|
608
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
|
|
609
|
+
return typeof code === 'string' && ABSENT_ENDPOINT_CODES.has(code)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function spawnOnce() {
|
|
613
|
+
if (typeof spawnDaemon !== 'function') return
|
|
614
|
+
try {
|
|
615
|
+
spawnDaemon()
|
|
616
|
+
} catch {
|
|
617
|
+
// Spawning is best effort — a `null` daemon degrades, it never refuses.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Dial the daemon, spawning it once when the endpoint is simply not there.
|
|
623
|
+
*
|
|
624
|
+
* Spawning is per attempt, not per process: after a failure this client must
|
|
625
|
+
* be able to start the daemon again on the next call, because "the daemon is
|
|
626
|
+
* gone" is a transient condition, not a decision to stop arbitrating.
|
|
627
|
+
*
|
|
628
|
+
* The wait is bounded by this client's lifetime, never by a dial timeout: a
|
|
629
|
+
* close answers it through the latch, and `connect` is left to settle on its
|
|
630
|
+
* own. A dial that never returns therefore cannot park a caller.
|
|
631
|
+
*
|
|
632
|
+
* @returns {Promise<LeaseConnection>}
|
|
633
|
+
*/
|
|
634
|
+
async function openConnection() {
|
|
635
|
+
// A close requested before or during the dial is terminal: never spawn,
|
|
636
|
+
// adopt or keep waiting for an arbiter on behalf of a client that is gone.
|
|
637
|
+
if (closed) throw transportError('lease client closed')
|
|
638
|
+
const first = await attemptConnect()
|
|
639
|
+
if (closed) {
|
|
640
|
+
closeSocket(first.connection)
|
|
641
|
+
throw transportError('lease client closed')
|
|
642
|
+
}
|
|
643
|
+
if (first.connection !== null) return first.connection
|
|
644
|
+
if (!isAbsentEndpoint(first.error)) {
|
|
645
|
+
throw transportError(`lease broker is unreachable at ${endpoint}`, first.error)
|
|
646
|
+
}
|
|
647
|
+
spawnOnce()
|
|
648
|
+
const deadline = now() + connectRetryBudgetMs
|
|
649
|
+
// A frozen or absent clock must not turn this into an endless loop: the
|
|
650
|
+
// attempt count is what actually bounds the wait.
|
|
651
|
+
const maxAttempts = Math.max(1, Math.ceil(connectRetryBudgetMs / Math.max(1, connectRetryMs))) + 2
|
|
652
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
653
|
+
if (closed) throw transportError('lease client closed')
|
|
654
|
+
if (now() >= deadline) break
|
|
655
|
+
await untilClosed(delay(connectRetryMs))
|
|
656
|
+
if (closed) throw transportError('lease client closed')
|
|
657
|
+
const retry = await attemptConnect()
|
|
658
|
+
if (closed) {
|
|
659
|
+
closeSocket(retry.connection)
|
|
660
|
+
throw transportError('lease client closed')
|
|
661
|
+
}
|
|
662
|
+
if (retry.connection !== null) return retry.connection
|
|
663
|
+
if (!isAbsentEndpoint(retry.error)) {
|
|
664
|
+
throw transportError(`lease broker is unreachable at ${endpoint}`, retry.error)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
throw transportError(`lease broker did not come up at ${endpoint} within ${connectRetryBudgetMs}ms`)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Establish the connection and complete the handshake, replaying retained
|
|
672
|
+
* leases when the daemon turns out to be a different incarnation (D6).
|
|
673
|
+
* @returns {Promise<LeaseConnection>}
|
|
674
|
+
*/
|
|
675
|
+
async function establish() {
|
|
676
|
+
const fresh = await openConnection()
|
|
677
|
+
// A close during the dial owns the socket now: it is closed instead of
|
|
678
|
+
// installed, so no handshake, replay or lease can happen after close.
|
|
679
|
+
if (closed) {
|
|
680
|
+
closeSocket(fresh)
|
|
681
|
+
throw transportError('lease client closed')
|
|
682
|
+
}
|
|
683
|
+
const previousStartedAt = startedAt
|
|
684
|
+
connection = fresh
|
|
685
|
+
greeted = false
|
|
686
|
+
let buffered = ''
|
|
687
|
+
fresh.onData((chunk) => {
|
|
688
|
+
buffered += chunk
|
|
689
|
+
let index = buffered.indexOf('\n')
|
|
690
|
+
while (index >= 0) {
|
|
691
|
+
const line = buffered.slice(0, index)
|
|
692
|
+
buffered = buffered.slice(index + 1)
|
|
693
|
+
if (line.trim() !== '') handleFrame(line)
|
|
694
|
+
index = buffered.indexOf('\n')
|
|
695
|
+
}
|
|
696
|
+
})
|
|
697
|
+
fresh.onClose(() => handleDisconnect(fresh))
|
|
698
|
+
/** @type {any} */
|
|
699
|
+
let hello
|
|
700
|
+
try {
|
|
701
|
+
hello = await sendFrame(fresh, { op: 'hello', protocol })
|
|
702
|
+
} catch (error) {
|
|
703
|
+
dropConnection(fresh)
|
|
704
|
+
throw transportError(`lease broker handshake failed at ${endpoint}`, error)
|
|
705
|
+
}
|
|
706
|
+
// A close may have invalidated this connection while the hello was in
|
|
707
|
+
// flight; even an already-delivered reply must not resurrect it.
|
|
708
|
+
if (closed) {
|
|
709
|
+
dropConnection(fresh)
|
|
710
|
+
throw transportError('lease client closed')
|
|
711
|
+
}
|
|
712
|
+
if (hello.ok !== true) {
|
|
713
|
+
// A protocol mismatch is infrastructure, not a conflict: the write goes
|
|
714
|
+
// through and the audit records why (D7). Nobody is asked to fix anything.
|
|
715
|
+
const code = typeof hello.error?.code === 'string' ? hello.error.code : 'unknown'
|
|
716
|
+
dropConnection(fresh)
|
|
717
|
+
throw transportError(`lease broker refused the handshake (${code})`)
|
|
718
|
+
}
|
|
719
|
+
greeted = true
|
|
720
|
+
// The handshake is the ONLY source of the push-event list, and an older
|
|
721
|
+
// broker simply does not carry the field: empty means "not advertised".
|
|
722
|
+
eventCapabilities = new Set(
|
|
723
|
+
Array.isArray(hello.events) ? hello.events.filter((entry) => typeof entry === 'string' && entry.length > 0) : [],
|
|
724
|
+
)
|
|
725
|
+
const reported = typeof hello.startedAt === 'number' ? hello.startedAt : previousStartedAt
|
|
726
|
+
startedAt = reported
|
|
727
|
+
const restarted = replayPending || (previousStartedAt !== undefined && previousStartedAt !== reported)
|
|
728
|
+
replayPending = false
|
|
729
|
+
// Arm the replay instead of running it here: the connection is usable, but
|
|
730
|
+
// replaying from inside `establish` would re-enter the request path. The next
|
|
731
|
+
// request flushes it (or `replayHolds()` does, without replaying twice).
|
|
732
|
+
if (restarted && ledger.size > 0) replayQueued = true
|
|
733
|
+
return fresh
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** @returns {Promise<LeaseConnection>} */
|
|
737
|
+
async function ensureConnection() {
|
|
738
|
+
if (closed) throw transportError('lease client is closed')
|
|
739
|
+
/** @type {LeaseConnection} */
|
|
740
|
+
let target
|
|
741
|
+
if (connection !== null && greeted) {
|
|
742
|
+
target = connection
|
|
743
|
+
} else if (connecting !== null) {
|
|
744
|
+
target = await connecting
|
|
745
|
+
} else {
|
|
746
|
+
/** @type {Promise<LeaseConnection>} */
|
|
747
|
+
const attempt = establish().finally(() => {
|
|
748
|
+
if (connecting === attempt) connecting = null
|
|
749
|
+
})
|
|
750
|
+
connecting = attempt
|
|
751
|
+
target = await attempt
|
|
752
|
+
}
|
|
753
|
+
// A close that landed while this dial was in flight is terminal: a caller
|
|
754
|
+
// resuming afterwards must never speak on the abandoned connection.
|
|
755
|
+
if (closed) throw transportError('lease client is closed')
|
|
756
|
+
return target
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Send one protocol request on a live connection.
|
|
761
|
+
* @param {string} op
|
|
762
|
+
* @param {Record<string, any>} [payload]
|
|
763
|
+
* @returns {Promise<any>}
|
|
764
|
+
*/
|
|
765
|
+
async function request(op, payload = {}) {
|
|
766
|
+
if (typeof op !== 'string' || op.length === 0) throw new TypeError('lease request requires an operation name')
|
|
767
|
+
const target = await ensureConnection()
|
|
768
|
+
if (closed) throw transportError('lease client is closed')
|
|
769
|
+
if (replayQueued) {
|
|
770
|
+
// Flush before the caller's own frame so an observe-after-reconnect call
|
|
771
|
+
// reports the world the replay has already restored.
|
|
772
|
+
replayQueued = false
|
|
773
|
+
await runReplay()
|
|
774
|
+
// The replay is wire I/O too: a close during it stops this request as well.
|
|
775
|
+
if (closed) throw transportError('lease client is closed')
|
|
776
|
+
}
|
|
777
|
+
return await exchange(target, op, payload)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---------------------------------------------------------------------
|
|
781
|
+
// Ledger: what we hold, what a turn boundary releases, what a reconnect replays
|
|
782
|
+
// ---------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* @param {any} owner
|
|
786
|
+
* @returns {{ id: string, session: string, label: string }}
|
|
787
|
+
*/
|
|
788
|
+
function normalizeOwner(owner) {
|
|
789
|
+
const rawId = typeof owner === 'string' ? owner : owner?.id
|
|
790
|
+
const id = typeof rawId === 'string' && rawId.length > 0 ? rawId : undefined
|
|
791
|
+
if (id === undefined) throw new TypeError('lease owner requires a non-empty id (the lineage)')
|
|
792
|
+
const session = typeof owner?.session === 'string' && owner.session.length > 0 ? owner.session : id
|
|
793
|
+
const label = typeof owner?.label === 'string' && owner.label.length > 0 ? owner.label : session
|
|
794
|
+
return { id, session, label }
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Normalise and validate the path batch. A relative path is a wiring bug, and
|
|
799
|
+
* the broker would only answer `invalid-path`, which this client would have to
|
|
800
|
+
* degrade — so it is refused loudly here instead of silently losing
|
|
801
|
+
* arbitration for every write of that tool.
|
|
802
|
+
* @param {unknown} paths
|
|
803
|
+
* @returns {string[]}
|
|
804
|
+
*/
|
|
805
|
+
function normalizePaths(paths) {
|
|
806
|
+
if (!Array.isArray(paths)) throw new TypeError('lease paths must be an array')
|
|
807
|
+
/** @type {string[]} */
|
|
808
|
+
const out = []
|
|
809
|
+
for (const path of paths) {
|
|
810
|
+
if (typeof path !== 'string' || path.length === 0) throw new TypeError('lease paths must be non-empty strings')
|
|
811
|
+
if (path.includes('\u0000')) throw new TypeError('lease paths may not contain NUL bytes')
|
|
812
|
+
if (!isAbsolute(path)) throw new TypeError(`lease paths must be absolute: ${path}`)
|
|
813
|
+
if (!out.includes(path)) out.push(path)
|
|
814
|
+
}
|
|
815
|
+
return out
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** @param {unknown} scope @returns {string} */
|
|
819
|
+
function requireScope(scope) {
|
|
820
|
+
if (typeof scope !== 'string' || scope.length === 0) {
|
|
821
|
+
throw new TypeError('lease scope must be a non-empty string (the project root)')
|
|
822
|
+
}
|
|
823
|
+
return scope
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* @param {string} lineage
|
|
828
|
+
* @param {string} session
|
|
829
|
+
* @returns {{ id: string, session: string, label: string }}
|
|
830
|
+
*/
|
|
831
|
+
function ownerOf(lineage, session) {
|
|
832
|
+
return { id: lineage, session, label: labels.get(lineage) ?? session }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* The owner to speak as for paths this client already holds. `hold` extends
|
|
837
|
+
* ownership, so an owner can only be inferred from something already owned; a
|
|
838
|
+
* batch we hold nothing of is refused locally as `not-owned` (a closed-vocabulary
|
|
839
|
+
* code) instead of sending a frame the broker would reject anyway.
|
|
840
|
+
* @param {string[]} paths
|
|
841
|
+
* @returns {{ id: string, session: string, label: string }}
|
|
842
|
+
*/
|
|
843
|
+
function ownerForPaths(paths) {
|
|
844
|
+
for (const path of paths) {
|
|
845
|
+
const record = ledger.get(path)
|
|
846
|
+
if (record !== undefined) return ownerOf(record.lineage, record.session)
|
|
847
|
+
}
|
|
848
|
+
throw leaseError('not-owned', 'this client holds none of the requested paths, so there is no owner to extend', {
|
|
849
|
+
paths,
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Record a granted batch. Only the fields the ledger exists for are kept: who
|
|
855
|
+
* to release as, and whether a retention must be replayed.
|
|
856
|
+
* @param {any[]} granted
|
|
857
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
858
|
+
* @param {string} scope
|
|
859
|
+
*/
|
|
860
|
+
function remember(granted, owner, scope) {
|
|
861
|
+
lastScope = scope
|
|
862
|
+
labels.set(owner.id, owner.label)
|
|
863
|
+
for (const lease of granted) {
|
|
864
|
+
const path = typeof lease?.path === 'string' ? lease.path : undefined
|
|
865
|
+
if (path === undefined) continue
|
|
866
|
+
ledger.set(path, {
|
|
867
|
+
session: owner.session,
|
|
868
|
+
lineage: owner.id,
|
|
869
|
+
holdUntil: typeof lease.holdUntil === 'number' ? lease.holdUntil : null,
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Record a successful retention.
|
|
876
|
+
* @param {any} reply
|
|
877
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
878
|
+
*/
|
|
879
|
+
function applyHold(reply, owner) {
|
|
880
|
+
const held = Array.isArray(reply?.held) ? reply.held : []
|
|
881
|
+
labels.set(owner.id, owner.label)
|
|
882
|
+
for (const entry of held) {
|
|
883
|
+
const path = typeof entry?.path === 'string' ? entry.path : undefined
|
|
884
|
+
if (path === undefined) continue
|
|
885
|
+
const holdUntil = typeof entry.holdUntil === 'number' ? entry.holdUntil : null
|
|
886
|
+
ledger.set(path, { session: owner.session, lineage: owner.id, holdUntil })
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ---------------------------------------------------------------------
|
|
891
|
+
// Arbitration-facing operations
|
|
892
|
+
// ---------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Acquire a batch up front (the explicit `edit_lock_acquire` form of what every
|
|
896
|
+
* managed write does implicitly).
|
|
897
|
+
* @param {any} owner
|
|
898
|
+
* @param {string[]} paths
|
|
899
|
+
* @param {string} scope
|
|
900
|
+
* @returns {Promise<any>}
|
|
901
|
+
*/
|
|
902
|
+
async function acquire(owner, paths, scope) {
|
|
903
|
+
const holder = normalizeOwner(owner)
|
|
904
|
+
const list = normalizePaths(paths)
|
|
905
|
+
const leaseScope = requireScope(scope)
|
|
906
|
+
const reply = await request('acquire', { owner: holder, scope: leaseScope, paths: list })
|
|
907
|
+
if (Array.isArray(reply.granted)) remember(reply.granted, holder, leaseScope)
|
|
908
|
+
return reply
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Attempt the batch again on a brand-new connection (the single retry D7
|
|
913
|
+
* allows). Returns `false` when the retry failed too; a real conflict is
|
|
914
|
+
* rethrown, because conflicts are never degraded.
|
|
915
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
916
|
+
* @param {string[]} paths
|
|
917
|
+
* @param {string} scope
|
|
918
|
+
* @returns {Promise<boolean>}
|
|
919
|
+
*/
|
|
920
|
+
async function retryAcquire(owner, paths, scope) {
|
|
921
|
+
const stale = connection
|
|
922
|
+
if (stale !== null) dropConnection(stale)
|
|
923
|
+
try {
|
|
924
|
+
await acquire(owner, paths, scope)
|
|
925
|
+
return true
|
|
926
|
+
} catch (error) {
|
|
927
|
+
if (isLeaseConflict(error)) throw error
|
|
928
|
+
const broken = connection
|
|
929
|
+
if (broken !== null) dropConnection(broken)
|
|
930
|
+
return false
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Acquire the batch, or run the write anyway (D7).
|
|
936
|
+
*
|
|
937
|
+
* One retry on a fresh connection at most; when that fails too, a single audit
|
|
938
|
+
* record explains the silent loss of mutual exclusion and `fn` proceeds with no
|
|
939
|
+
* lease. A real conflict is rethrown on every path — including out of the retry.
|
|
940
|
+
*
|
|
941
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
942
|
+
* @param {string[]} paths
|
|
943
|
+
* @param {string} scope
|
|
944
|
+
* @returns {Promise<void>}
|
|
945
|
+
*/
|
|
946
|
+
async function acquireOrDegrade(owner, paths, scope) {
|
|
947
|
+
try {
|
|
948
|
+
await acquire(owner, paths, scope)
|
|
949
|
+
return
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if (isLeaseConflict(error)) throw error
|
|
952
|
+
if (await retryAcquire(owner, paths, scope)) return
|
|
953
|
+
auditDegraded({
|
|
954
|
+
code: errorCode(error),
|
|
955
|
+
session: owner.session,
|
|
956
|
+
lineage: owner.id,
|
|
957
|
+
scope,
|
|
958
|
+
paths: paths.length,
|
|
959
|
+
})
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Hold the leases for `owner` for the duration of `fn` (design D2): the leases
|
|
965
|
+
* are acquired once and NOT released in a `finally` — ownership lasts until the
|
|
966
|
+
* turn ends (`releaseSession`) or the process does (`releaseAll`/connection
|
|
967
|
+
* close). `fn` runs exactly once on every path that is not a real conflict.
|
|
968
|
+
*
|
|
969
|
+
* @template T
|
|
970
|
+
* @param {any} owner `{id, session, label}` (or a bare lineage id)
|
|
971
|
+
* @param {string[]} paths absolute paths
|
|
972
|
+
* @param {string} scope opaque project root, used only for `status` filtering
|
|
973
|
+
* @param {() => T | Promise<T>} fn the managed operation
|
|
974
|
+
* @returns {Promise<T>}
|
|
975
|
+
*/
|
|
976
|
+
async function withLeases(owner, paths, scope, fn) {
|
|
977
|
+
if (typeof fn !== 'function') throw new TypeError('withLeases requires a callback')
|
|
978
|
+
const holder = normalizeOwner(owner)
|
|
979
|
+
const list = normalizePaths(paths)
|
|
980
|
+
const leaseScope = requireScope(scope)
|
|
981
|
+
if (list.length > 0) await acquireOrDegrade(holder, list, leaseScope)
|
|
982
|
+
return await fn()
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Extend the leases for `paths` (only ever later, ≤ 60 minutes; the broker
|
|
987
|
+
* owns that rule). The owner is inferred from the ledger when omitted, which is
|
|
988
|
+
* the only legitimate case: `hold` applies to something already held.
|
|
989
|
+
* @param {string[]} paths
|
|
990
|
+
* @param {number} minutes
|
|
991
|
+
* @param {any} [owner]
|
|
992
|
+
* @returns {Promise<any>}
|
|
993
|
+
*/
|
|
994
|
+
async function hold(paths, minutes, owner) {
|
|
995
|
+
const list = normalizePaths(paths)
|
|
996
|
+
const holder = owner === undefined ? ownerForPaths(list) : normalizeOwner(owner)
|
|
997
|
+
const reply = await request('hold', { owner: holder, paths: list, minutes })
|
|
998
|
+
applyHold(reply, holder)
|
|
999
|
+
return reply
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Read-only projection. Filters stack; only the protocol's three filter keys
|
|
1004
|
+
* are forwarded (the frame grammar rejects extras).
|
|
1005
|
+
* @param {{ scope?: string, session?: string, path?: string }} [filter]
|
|
1006
|
+
* @returns {Promise<any>}
|
|
1007
|
+
*/
|
|
1008
|
+
function status(filter = {}) {
|
|
1009
|
+
/** @type {Record<string, string>} */
|
|
1010
|
+
const payload = {}
|
|
1011
|
+
if (typeof filter?.scope === 'string' && filter.scope.length > 0) payload.scope = filter.scope
|
|
1012
|
+
if (typeof filter?.session === 'string' && filter.session.length > 0) payload.session = filter.session
|
|
1013
|
+
if (typeof filter?.path === 'string' && filter.path.length > 0) payload.path = filter.path
|
|
1014
|
+
return request('status', payload)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Read-only connection health for the status surface (design D13).
|
|
1019
|
+
*
|
|
1020
|
+
* `ok` means the handshake stands right now, so a `status` read reaches the
|
|
1021
|
+
* arbiter; `degraded` means this process HAS been arbitrated before but the
|
|
1022
|
+
* connection is gone at this instant (the next write would degrade and
|
|
1023
|
+
* proceed, D7); `unavailable` means it was never reached in this process.
|
|
1024
|
+
*
|
|
1025
|
+
* Reporting only: it opens nothing, closes nothing and retries nothing, so a
|
|
1026
|
+
* reader can never change an arbitration verdict by looking.
|
|
1027
|
+
*
|
|
1028
|
+
* @returns {'ok' | 'degraded' | 'unavailable'}
|
|
1029
|
+
*/
|
|
1030
|
+
function connectionState() {
|
|
1031
|
+
if (closed) return 'unavailable'
|
|
1032
|
+
if (connection !== null && greeted) return 'ok'
|
|
1033
|
+
// `startedAt` is set by the first successful handshake and never cleared:
|
|
1034
|
+
// its presence is exactly "this process once had an arbiter".
|
|
1035
|
+
return startedAt === undefined ? 'unavailable' : 'degraded'
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Ask the holder of `path` to hand it over (negotiation, D4).
|
|
1040
|
+
* @param {string} path
|
|
1041
|
+
* @param {any} from the requesting owner
|
|
1042
|
+
* @returns {Promise<any>}
|
|
1043
|
+
*/
|
|
1044
|
+
function requestTransfer(path, from) {
|
|
1045
|
+
if (typeof path !== 'string' || path.length === 0) throw new TypeError('requestTransfer requires a path')
|
|
1046
|
+
return request('request', { from: normalizeOwner(from), path })
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Answer a pending transfer request as the holder.
|
|
1051
|
+
* @param {{ requestId: string, owner: any, decision: 'release' | 'keep' }} input
|
|
1052
|
+
* @returns {Promise<any>}
|
|
1053
|
+
*/
|
|
1054
|
+
function reply(input) {
|
|
1055
|
+
const requestId = input?.requestId
|
|
1056
|
+
if (typeof requestId !== 'string' || requestId.length === 0) throw new TypeError('reply requires a requestId')
|
|
1057
|
+
const decision = input?.decision
|
|
1058
|
+
if (decision !== 'release' && decision !== 'keep') throw new TypeError("reply decision must be 'release' or 'keep'")
|
|
1059
|
+
return request('reply', { owner: normalizeOwner(input?.owner), requestId, decision })
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ---------------------------------------------------------------------
|
|
1063
|
+
// Release and replay
|
|
1064
|
+
// ---------------------------------------------------------------------
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Release everything this session holds EXCEPT retentions (design D10): the
|
|
1068
|
+
* `turn/end` rule. A failure is swallowed — turn-boundary release is an
|
|
1069
|
+
* optimisation, and the daemon's disconnect rule plus the TTL already cover it.
|
|
1070
|
+
* @param {string} session
|
|
1071
|
+
* @returns {Promise<{ released: string[] }>}
|
|
1072
|
+
*/
|
|
1073
|
+
async function releaseSession(session) {
|
|
1074
|
+
return await releaseMatching((record) => record.session === session && record.holdUntil === null)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Release everything this session holds, retentions included (design D10): the
|
|
1079
|
+
* `agent/disposed` rule — a disposed agent never comes back.
|
|
1080
|
+
* @param {string} session
|
|
1081
|
+
* @returns {Promise<{ released: string[] }>}
|
|
1082
|
+
*/
|
|
1083
|
+
async function releaseAll(session) {
|
|
1084
|
+
return await releaseMatching((record) => record.session === session)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* @param {(record: LeaseRecord) => boolean} match
|
|
1089
|
+
* @returns {Promise<{ released: string[] }>}
|
|
1090
|
+
*/
|
|
1091
|
+
async function releaseMatching(match) {
|
|
1092
|
+
/** @type {Map<string, { session: string, paths: string[] }>} */
|
|
1093
|
+
const groups = new Map()
|
|
1094
|
+
for (const [path, record] of [...ledger]) {
|
|
1095
|
+
if (!match(record)) continue
|
|
1096
|
+
// The intent is "stop holding this", and a failed frame cannot undo the
|
|
1097
|
+
// intent: the entry goes, whatever the transport does next.
|
|
1098
|
+
ledger.delete(path)
|
|
1099
|
+
const group = groups.get(record.lineage) ?? { session: record.session, paths: [] }
|
|
1100
|
+
group.session = record.session
|
|
1101
|
+
group.paths.push(path)
|
|
1102
|
+
groups.set(record.lineage, group)
|
|
1103
|
+
}
|
|
1104
|
+
/** @type {string[]} */
|
|
1105
|
+
const released = []
|
|
1106
|
+
for (const [lineage, group] of groups) {
|
|
1107
|
+
try {
|
|
1108
|
+
const response = await request('release', { owner: ownerOf(lineage, group.session), paths: group.paths })
|
|
1109
|
+
if (Array.isArray(response.released)) released.push(...response.released)
|
|
1110
|
+
} catch {
|
|
1111
|
+
// See above: never throw at a turn boundary, never keep state to retry.
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return { released }
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Replay every still-valid retention (`holdUntil > now`) so a daemon restart
|
|
1119
|
+
* does not silently drop a hold. An entry somebody else now owns is removed
|
|
1120
|
+
* without an error: the next write sees an ordinary conflict (D6).
|
|
1121
|
+
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
1122
|
+
*/
|
|
1123
|
+
async function replayHolds() {
|
|
1124
|
+
await ensureConnection()
|
|
1125
|
+
if (closed) throw transportError('lease client is closed')
|
|
1126
|
+
// An explicit replay satisfies a queued one: never send the same retention
|
|
1127
|
+
// twice just because a reconnect happened to arm it.
|
|
1128
|
+
replayQueued = false
|
|
1129
|
+
return await runReplay()
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
1134
|
+
*/
|
|
1135
|
+
function runReplay() {
|
|
1136
|
+
if (replaying !== null) return replaying
|
|
1137
|
+
/** @type {Promise<{ replayed: number, dropped: number }>} */
|
|
1138
|
+
const attempt = replayOnce().finally(() => {
|
|
1139
|
+
if (replaying === attempt) replaying = null
|
|
1140
|
+
})
|
|
1141
|
+
replaying = attempt
|
|
1142
|
+
return attempt
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
1147
|
+
*/
|
|
1148
|
+
async function replayOnce() {
|
|
1149
|
+
let replayed = 0
|
|
1150
|
+
let dropped = 0
|
|
1151
|
+
const at = now()
|
|
1152
|
+
for (const [path, record] of [...ledger]) {
|
|
1153
|
+
if (record.holdUntil === null || record.holdUntil <= at) continue
|
|
1154
|
+
const owner = ownerOf(record.lineage, record.session)
|
|
1155
|
+
const scope = lastScope
|
|
1156
|
+
try {
|
|
1157
|
+
// Re-acquire first: after a daemon restart the table is empty, so a bare
|
|
1158
|
+
// `hold` would be `not-owned`. The pair is idempotent by protocol.
|
|
1159
|
+
await acquire(owner, [path], requireScope(scope))
|
|
1160
|
+
const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
|
|
1161
|
+
applyHold(response, owner)
|
|
1162
|
+
replayed += 1
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
if (errorCode(error) === TRANSPORT_CODE) {
|
|
1165
|
+
// The connection died again: keep the rest for the next reconnect
|
|
1166
|
+
// rather than forgetting a retention nobody has seen fail.
|
|
1167
|
+
break
|
|
1168
|
+
}
|
|
1169
|
+
ledger.delete(path)
|
|
1170
|
+
dropped += 1
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return { replayed, dropped }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Remaining retention as `hold` minutes, clamped to the protocol's closed
|
|
1178
|
+
* interval `(0, 60]` and rounded UP so a replay never shortens a hold.
|
|
1179
|
+
* @param {number} remainingMs
|
|
1180
|
+
* @returns {number}
|
|
1181
|
+
*/
|
|
1182
|
+
function holdMinutes(remainingMs) {
|
|
1183
|
+
const minutes = Math.ceil((remainingMs / 60_000) * 1000) / 1000
|
|
1184
|
+
return Math.min(HOLD_MAX_MINUTES, Math.max(0.001, minutes))
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// ---------------------------------------------------------------------
|
|
1188
|
+
// Degrade accounting, push events, shutdown
|
|
1189
|
+
// ---------------------------------------------------------------------
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Record one degraded write. Rate limited per process (D7) so a broken daemon
|
|
1193
|
+
* cannot flood the audit log, and never allowed to throw.
|
|
1194
|
+
* @param {Record<string, any>} detail
|
|
1195
|
+
*/
|
|
1196
|
+
function auditDegraded(detail) {
|
|
1197
|
+
const at = now()
|
|
1198
|
+
if (lastDegradedAt !== null && at - lastDegradedAt < degradedAuditWindowMs) return
|
|
1199
|
+
lastDegradedAt = at
|
|
1200
|
+
try {
|
|
1201
|
+
audit('degraded', { ...detail, at })
|
|
1202
|
+
} catch {
|
|
1203
|
+
// Audit is log-only; it must never break a write.
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Subscribe to broker-pushed `request` / `settled` / `released` events.
|
|
1209
|
+
* @param {(event: any) => void} handler
|
|
1210
|
+
* @returns {() => void} unsubscribe
|
|
1211
|
+
*/
|
|
1212
|
+
function onEvent(handler) {
|
|
1213
|
+
if (typeof handler !== 'function') throw new TypeError('onEvent requires a handler')
|
|
1214
|
+
eventHandlers.add(handler)
|
|
1215
|
+
return () => {
|
|
1216
|
+
eventHandlers.delete(handler)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Subscribe to connection resets (D5). A reset means the current connection
|
|
1222
|
+
* is gone, so anything that was waiting on a push from it is over — there is
|
|
1223
|
+
* nothing to replay and no fact to infer about ownership.
|
|
1224
|
+
* @param {() => void} handler
|
|
1225
|
+
* @returns {() => void} unsubscribe
|
|
1226
|
+
*/
|
|
1227
|
+
function onReset(handler) {
|
|
1228
|
+
if (typeof handler !== 'function') throw new TypeError('onReset requires a handler')
|
|
1229
|
+
resetHandlers.add(handler)
|
|
1230
|
+
return () => {
|
|
1231
|
+
resetHandlers.delete(handler)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Read-only capability query: did the CURRENT handshake advertise this push
|
|
1237
|
+
* event? An older broker, a closed client and a dropped connection all answer
|
|
1238
|
+
* `false` — the caller must report the capability as unavailable instead of
|
|
1239
|
+
* waiting for an event that cannot arrive.
|
|
1240
|
+
* @param {string} name
|
|
1241
|
+
* @returns {boolean}
|
|
1242
|
+
*/
|
|
1243
|
+
function supportsEvent(name) {
|
|
1244
|
+
return typeof name === 'string' && eventCapabilities.has(name)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Close the client. The daemon keeps running: it is shared with every other
|
|
1249
|
+
* client on this machine, and its own idle window is what ends it.
|
|
1250
|
+
*
|
|
1251
|
+
* A close is terminal for the whole transport lifetime, including a dial or
|
|
1252
|
+
* handshake that is still in flight: the callers waiting on that attempt are
|
|
1253
|
+
* settled with a transport failure the moment the close lands (even when the
|
|
1254
|
+
* underlying dial never returns), the late socket is closed instead of
|
|
1255
|
+
* adopted, and any subscription waiting on it is told with exactly one reset
|
|
1256
|
+
* notification.
|
|
1257
|
+
*/
|
|
1258
|
+
function close() {
|
|
1259
|
+
if (closed) return
|
|
1260
|
+
closed = true
|
|
1261
|
+
ledger.clear()
|
|
1262
|
+
const target = connection
|
|
1263
|
+
if (target !== null) {
|
|
1264
|
+
// An intentional close ends the current connection like any other loss, so
|
|
1265
|
+
// a subscription waiting on it is told instead of waiting forever.
|
|
1266
|
+
invalidateConnection(target, true)
|
|
1267
|
+
closeSocket(target)
|
|
1268
|
+
notifyCloseWaiters()
|
|
1269
|
+
return
|
|
1270
|
+
}
|
|
1271
|
+
// No connection is installed yet, but a dial or handshake may still be in
|
|
1272
|
+
// flight. `establish` closes its late socket rather than adopting it and the
|
|
1273
|
+
// latch answers the callers now; the reset is reported here because no
|
|
1274
|
+
// connection exists to be invalidated later.
|
|
1275
|
+
const dialing = connecting !== null
|
|
1276
|
+
failWaiters(transportError('lease client closed'))
|
|
1277
|
+
if (dialing) notifyReset()
|
|
1278
|
+
notifyCloseWaiters()
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
return Object.freeze({
|
|
1282
|
+
endpoint,
|
|
1283
|
+
request,
|
|
1284
|
+
acquire,
|
|
1285
|
+
withLeases,
|
|
1286
|
+
hold,
|
|
1287
|
+
status,
|
|
1288
|
+
connectionState,
|
|
1289
|
+
requestTransfer,
|
|
1290
|
+
reply,
|
|
1291
|
+
releaseSession,
|
|
1292
|
+
releaseAll,
|
|
1293
|
+
replayHolds,
|
|
1294
|
+
onEvent,
|
|
1295
|
+
onReset,
|
|
1296
|
+
supportsEvent,
|
|
1297
|
+
close,
|
|
1298
|
+
})
|
|
1299
|
+
}
|