@gotcos/glasses-server 6.27.13 → 6.29.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/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/server/index.ts +186 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +2024 -0
- package/server/routes/health.ts +24 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Which provider processes did COS itself start?
|
|
2
|
+
//
|
|
3
|
+
// Plan 4.4 (self-recursion prevention). This is the ONLY sound basis for
|
|
4
|
+
// self-exclusion in the occupancy detector, because nothing a provider process
|
|
5
|
+
// reports about itself can distinguish our spawn from a human's desktop window.
|
|
6
|
+
// Verified on this machine 2026-08-15: a CLI `claude -p` spawned by Node writes
|
|
7
|
+
// `~/.claude/sessions/<pid>.json` with `kind: "interactive"` and
|
|
8
|
+
// `entrypoint: "claude-desktop"` — byte-identical to the live desktop record for
|
|
9
|
+
// pid 7872. There is no self-reported field to filter on. Either we remember
|
|
10
|
+
// what we started, or we cannot tell our own process from the user's.
|
|
11
|
+
//
|
|
12
|
+
// WHY THE VALUE IS A START TIME AND NOT A BOOLEAN. `cosSpawnedPids` is the one
|
|
13
|
+
// input to `threadOccupancy` that can turn a LIVE owner into `attachable`.
|
|
14
|
+
// Every other input can only add doubt. A bare `Set<number>` would mean a
|
|
15
|
+
// recycled PID inherits our claim and unlocks a stranger's live conversation,
|
|
16
|
+
// and PID reuse is not exotic — macOS wraps at 99999. The recorded start makes
|
|
17
|
+
// the claim checkable: `isSelfOwned` (thread-occupancy.ts) requires it to match
|
|
18
|
+
// the live process start within `PROC_START_TOLERANCE_MS`, so a recycled PID —
|
|
19
|
+
// which by construction started later — cannot forge membership.
|
|
20
|
+
//
|
|
21
|
+
// SCOPE, NAMED RATHER THAN IMPLIED. Plan 4.4 describes a durable ownership
|
|
22
|
+
// registry with four classes (`cos_created`, `externally_attached`, `external`,
|
|
23
|
+
// `unknown`). This file implements ONLY the in-process spawn ledger that such a
|
|
24
|
+
// registry would be forward-populated FROM. The durable classification store
|
|
25
|
+
// does not exist yet, and no caller should read absence from this ledger as
|
|
26
|
+
// `external`: absence here means "not a process this server started", which is
|
|
27
|
+
// the plan's `unknown` — Fork-only.
|
|
28
|
+
//
|
|
29
|
+
// EVERY UNKNOWN RESOLVES TO "NOT OURS". A rejected record, an expired entry, an
|
|
30
|
+
// evicted entry, a snapshot that failed internally: all produce a ledger that
|
|
31
|
+
// omits the pid, which makes a live owner read as foreign, which makes the
|
|
32
|
+
// thread Fork-only. That is a lost feature, not a lost conversation. The
|
|
33
|
+
// opposite mistake — claiming a process we did not start — hands a stranger's
|
|
34
|
+
// live thread to a COS turn. There is no symmetric risk here, so every judgment
|
|
35
|
+
// call in this file goes the same way.
|
|
36
|
+
//
|
|
37
|
+
// FOUR THINGS THAT LOOK TRUE AND ARE NOT:
|
|
38
|
+
//
|
|
39
|
+
// 1. "detached: true means the pid we get back is a wrapper." It is not, for
|
|
40
|
+
// either provider. VERIFIED 2026-08-15 by spawning both exactly as the
|
|
41
|
+
// bridges do (`detached: true`, stdio pipes):
|
|
42
|
+
// - claude: `spawn('claude', ['-p', ...])` returned pid 98602 and Claude
|
|
43
|
+
// wrote `~/.claude/sessions/98602.json` containing `"pid": 98602`.
|
|
44
|
+
// `/opt/homebrew/bin/claude` is a symlink straight to a Mach-O binary,
|
|
45
|
+
// so there is no shell wrapper to insert a pid.
|
|
46
|
+
// - codex: the spawned pid 99301 was the exact pid `lsof -t` reported
|
|
47
|
+
// holding the new `~/.codex/thread-writer-locks/<uuid>.lock`.
|
|
48
|
+
// So the ledger is keyed on the spawned child pid. It does NOT need to
|
|
49
|
+
// cover the process group, and must not: `detached: true` makes the child a
|
|
50
|
+
// group LEADER (observed pgid 99301 == pid 99301), so a group-keyed ledger
|
|
51
|
+
// would sweep in any grandchild the provider forks and claim those too.
|
|
52
|
+
// 2. "record it whenever, the start time sorts it out." No: an old start value
|
|
53
|
+
// recorded late is a claim about a process that has been running for a
|
|
54
|
+
// while, i.e. possibly the user's. A claim is only accepted if it describes
|
|
55
|
+
// a process that started within `MAX_SPAWN_START_AGE_MS` of now.
|
|
56
|
+
// 3. "persisting the ledger would survive restarts, which is better." It is
|
|
57
|
+
// worse, and this is the one place the safe direction is also the correct
|
|
58
|
+
// one. Bridges spawn `detached: true`, so a provider child CAN outlive the
|
|
59
|
+
// server. A restarted server cannot kill it, cannot see its stdout, and
|
|
60
|
+
// cannot coordinate with it — it is exactly the second writer protocol 1
|
|
61
|
+
// exists to avoid. In-process-only means such a child reads as foreign and
|
|
62
|
+
// the thread is Fork-only. See `stats()` for how to tell that state apart
|
|
63
|
+
// from an idle ledger.
|
|
64
|
+
// 4. "an empty snapshot means nothing is running." It can also mean the clock
|
|
65
|
+
// probe threw. Both are safe, but they are different, so `stats()` counts
|
|
66
|
+
// them separately rather than leaving an operator to guess (the same
|
|
67
|
+
// detector-unavailable vs. detector-found-nothing distinction the occupancy
|
|
68
|
+
// module draws).
|
|
69
|
+
|
|
70
|
+
import { PROC_START_TOLERANCE_MS } from './thread-occupancy.js'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How long an entry survives after `record`, absent a `release`.
|
|
74
|
+
*
|
|
75
|
+
* The tension, stated so the choice is auditable: retain too long and a
|
|
76
|
+
* recycled PID gets a second chance at forging self-ownership (the start check
|
|
77
|
+
* still has to fail for that to bite, but defense in depth is the point of the
|
|
78
|
+
* bound); evict too early and a genuine COS child ages out mid-run, after which
|
|
79
|
+
* we treat our own process as a foreign writer. The second failure costs a
|
|
80
|
+
* Fork; the first costs someone's conversation. So the bound is deliberately
|
|
81
|
+
* near the low end of "long enough".
|
|
82
|
+
*
|
|
83
|
+
* 30 minutes is chosen against the longest legitimate run this server allows:
|
|
84
|
+
* `providerTimeoutMs` defaults to 21 minutes (query-job-coordinator.ts:136) and
|
|
85
|
+
* the Claude wall-clock ceiling tops out at 20 minutes
|
|
86
|
+
* (`WALL_MAX_DEEP_EFFORT_MS`, claude-bridge.ts:78). 30 covers both with margin
|
|
87
|
+
* and nothing longer is reachable, so a live entry expiring is a bug signal
|
|
88
|
+
* rather than routine.
|
|
89
|
+
*/
|
|
90
|
+
export const SPAWN_ENTRY_TTL_MS = 30 * 60_000
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Hard cap on tracked spawns. Overflow evicts the oldest entry, which is the
|
|
94
|
+
* fail-closed direction: the evicted process reverts to "not ours".
|
|
95
|
+
*
|
|
96
|
+
* 64 is far above the real concurrency (attached turns serialize on the native
|
|
97
|
+
* target, ordinary ones on the COS session), so hitting it means something is
|
|
98
|
+
* recording without releasing — which `stats().evictedCapacity` reports.
|
|
99
|
+
*/
|
|
100
|
+
export const MAX_TRACKED_SPAWNS = 64
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How stale a claimed start may be at `record` time.
|
|
104
|
+
*
|
|
105
|
+
* `record` is called from the provider-process callback microseconds-to-
|
|
106
|
+
* milliseconds after `spawn` returns, so the honest window is small. A minute
|
|
107
|
+
* absorbs a badly loaded event loop while still rejecting the dangerous input:
|
|
108
|
+
* a timestamp lifted from somewhere else, describing a process that was already
|
|
109
|
+
* running — which is the shape of the user's desktop app.
|
|
110
|
+
*/
|
|
111
|
+
export const MAX_SPAWN_START_AGE_MS = 60_000
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How far in the future a claimed start may be. Reuses the occupancy
|
|
115
|
+
* tolerance because it exists for the same reason: process-start readings on
|
|
116
|
+
* macOS have one-second resolution, so small disagreement is rounding, not a
|
|
117
|
+
* different process.
|
|
118
|
+
*/
|
|
119
|
+
export const MAX_SPAWN_START_SKEW_MS = PROC_START_TOLERANCE_MS
|
|
120
|
+
|
|
121
|
+
/** Any real pid fits; this only rejects garbage that reached us as a number. */
|
|
122
|
+
export const MAX_PLAUSIBLE_PID = 2 ** 31 - 1
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Why a claim was or was not accepted.
|
|
126
|
+
*
|
|
127
|
+
* Distinct reasons rather than a boolean: "we never recorded it" and "we
|
|
128
|
+
* rejected a malformed claim" produce the same safe verdict downstream but call
|
|
129
|
+
* for different fixes, and a caller that logs `rejected_start_not_recent` finds
|
|
130
|
+
* a wiring bug in one line.
|
|
131
|
+
*/
|
|
132
|
+
export type SpawnRecordOutcome =
|
|
133
|
+
| 'recorded'
|
|
134
|
+
| 'rejected_pid'
|
|
135
|
+
| 'rejected_self_pid'
|
|
136
|
+
| 'rejected_start'
|
|
137
|
+
| 'rejected_start_not_recent'
|
|
138
|
+
| 'rejected_internal'
|
|
139
|
+
|
|
140
|
+
export interface SpawnLedgerStats {
|
|
141
|
+
/** Live entries at the last successful prune. */
|
|
142
|
+
tracked: number
|
|
143
|
+
recorded: number
|
|
144
|
+
released: number
|
|
145
|
+
rejected: number
|
|
146
|
+
lastRejection: SpawnRecordOutcome | null
|
|
147
|
+
evictedExpired: number
|
|
148
|
+
evictedCapacity: number
|
|
149
|
+
/**
|
|
150
|
+
* Snapshots that failed internally and returned empty.
|
|
151
|
+
*
|
|
152
|
+
* Non-zero means an empty ledger may be a broken ledger rather than an idle
|
|
153
|
+
* one. The verdict is the same either way; the diagnosis is not.
|
|
154
|
+
*/
|
|
155
|
+
snapshotFailures: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface SpawnEntry {
|
|
159
|
+
/** Process start, epoch ms, as claimed by the spawner. */
|
|
160
|
+
startMs: number
|
|
161
|
+
/** When the claim was accepted. Retention is measured from here, not startMs. */
|
|
162
|
+
recordedAt: number
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SpawnLedgerOptions {
|
|
166
|
+
/** Injectable for tests. Must return epoch ms. */
|
|
167
|
+
now?: () => number
|
|
168
|
+
maxEntries?: number
|
|
169
|
+
ttlMs?: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isPlausiblePid(value: unknown): value is number {
|
|
173
|
+
// Number.isSafeInteger rejects NaN, ±Infinity and floats in one call.
|
|
174
|
+
// `> 0` matters beyond tidiness: 0 is the kernel and a NEGATIVE value is a
|
|
175
|
+
// process GROUP in kill(2) semantics, so neither may ever enter a pid map.
|
|
176
|
+
return typeof value === 'number'
|
|
177
|
+
&& Number.isSafeInteger(value)
|
|
178
|
+
&& value > 0
|
|
179
|
+
&& value <= MAX_PLAUSIBLE_PID
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The in-process record of provider children this server started.
|
|
184
|
+
*
|
|
185
|
+
* Not exported as a bare map anywhere: `snapshot()` hands out a copy, so a
|
|
186
|
+
* caller cannot write an entry into the ledger by mutating what it was given.
|
|
187
|
+
* That matters because the caller here is a probe passed into occupancy
|
|
188
|
+
* detection, and an injected entry is a forged self-ownership claim.
|
|
189
|
+
*/
|
|
190
|
+
export class CosSpawnLedger {
|
|
191
|
+
private readonly entries = new Map<number, SpawnEntry>()
|
|
192
|
+
private readonly clock: () => number
|
|
193
|
+
private readonly maxEntries: number
|
|
194
|
+
private readonly ttlMs: number
|
|
195
|
+
|
|
196
|
+
private recordedCount = 0
|
|
197
|
+
private releasedCount = 0
|
|
198
|
+
private rejectedCount = 0
|
|
199
|
+
private lastRejection: SpawnRecordOutcome | null = null
|
|
200
|
+
private evictedExpired = 0
|
|
201
|
+
private evictedCapacity = 0
|
|
202
|
+
private snapshotFailures = 0
|
|
203
|
+
|
|
204
|
+
constructor(options: SpawnLedgerOptions = {}) {
|
|
205
|
+
this.clock = options.now ?? Date.now
|
|
206
|
+
this.maxEntries = Number.isSafeInteger(options.maxEntries) && (options.maxEntries as number) > 0
|
|
207
|
+
? (options.maxEntries as number)
|
|
208
|
+
: MAX_TRACKED_SPAWNS
|
|
209
|
+
this.ttlMs = Number.isFinite(options.ttlMs) && (options.ttlMs as number) > 0
|
|
210
|
+
? (options.ttlMs as number)
|
|
211
|
+
: SPAWN_ENTRY_TTL_MS
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Claim a pid as ours.
|
|
216
|
+
*
|
|
217
|
+
* Both arguments are `unknown` on purpose. `ProviderProcessMetadata.pid` is
|
|
218
|
+
* optional (`pid?: number`, claude-bridge.ts:323), so `undefined` reaches
|
|
219
|
+
* here at runtime on any provider path that does not report one, and the
|
|
220
|
+
* compiler is not the thing standing between that and a forged claim.
|
|
221
|
+
*
|
|
222
|
+
* `startMs` MUST be `processStartMs(child.pid)` from occupancy-probes, NOT
|
|
223
|
+
* `Date.now()` at the spawn call. Both are compared later against the KERNEL
|
|
224
|
+
* start of the process, but only one of them matches it.
|
|
225
|
+
*
|
|
226
|
+
* Measured 2026-08-15, n=14 staggered across second boundaries:
|
|
227
|
+
* processStartMs(child.pid) delta exactly 0ms (same truncated value both sides)
|
|
228
|
+
* Date.now() at spawn delta 2ms to 992ms (uniform — it is the
|
|
229
|
+
* one-second truncation of the kernel reading)
|
|
230
|
+
*
|
|
231
|
+
* `PROC_START_TOLERANCE_MS` is 1500, so `Date.now()` leaves ~508ms of headroom
|
|
232
|
+
* for spawn latency. A `claude` CLI spawn under load exceeds that, and when it
|
|
233
|
+
* does, every COS-spawned owner reads as a FOREIGN desktop process and the
|
|
234
|
+
* thread is non-attachable forever — which looks exactly like a detector bug.
|
|
235
|
+
* The probe costs 3-7ms, not the 45ms once assumed.
|
|
236
|
+
*
|
|
237
|
+
* Never throws: it runs inside the bridge's provider-start try block, where
|
|
238
|
+
* an exception is treated as a provider start failure and kills the run.
|
|
239
|
+
*/
|
|
240
|
+
record(pid: unknown, startMs: unknown): SpawnRecordOutcome {
|
|
241
|
+
try {
|
|
242
|
+
if (!isPlausiblePid(pid)) return this.reject('rejected_pid')
|
|
243
|
+
// Our own pid is never a provider child. Accepting it would let the
|
|
244
|
+
// server vouch for itself if it ever appeared in a provider registry.
|
|
245
|
+
if (pid === process.pid) return this.reject('rejected_self_pid')
|
|
246
|
+
if (typeof startMs !== 'number' || !Number.isFinite(startMs) || startMs <= 0) {
|
|
247
|
+
return this.reject('rejected_start')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const now = this.clock()
|
|
251
|
+
if (!Number.isFinite(now)) return this.reject('rejected_internal')
|
|
252
|
+
const age = now - startMs
|
|
253
|
+
// Too old: describes a process that was already running, which is the
|
|
254
|
+
// shape of the user's desktop app rather than the child we just made.
|
|
255
|
+
// Too far ahead: a clock that cannot be reconciled with a kernel start.
|
|
256
|
+
if (age > MAX_SPAWN_START_AGE_MS || age < -MAX_SPAWN_START_SKEW_MS) {
|
|
257
|
+
return this.reject('rejected_start_not_recent')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Delete first so an overwrite of a live key cannot trigger a capacity
|
|
261
|
+
// eviction, and so the entry re-enters at the end of iteration order.
|
|
262
|
+
// Overwrite is correct: one pid cannot name two live processes, so a
|
|
263
|
+
// pre-existing entry for this pid is by definition stale.
|
|
264
|
+
this.entries.delete(pid)
|
|
265
|
+
this.prune(now)
|
|
266
|
+
this.evictToCapacity()
|
|
267
|
+
this.entries.set(pid, { startMs, recordedAt: now })
|
|
268
|
+
this.recordedCount++
|
|
269
|
+
return 'recorded'
|
|
270
|
+
} catch {
|
|
271
|
+
return this.reject('rejected_internal')
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Give up the claim, normally when the child exits.
|
|
277
|
+
*
|
|
278
|
+
* Deletion is always the safe direction, so this validates nothing and
|
|
279
|
+
* swallows nothing — an unknown pid is simply `false`.
|
|
280
|
+
*/
|
|
281
|
+
release(pid: unknown): boolean {
|
|
282
|
+
if (typeof pid !== 'number') return false
|
|
283
|
+
const had = this.entries.delete(pid)
|
|
284
|
+
if (had) this.releasedCount++
|
|
285
|
+
return had
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* pid -> process start, epoch ms. The `OccupancyProbes.cosSpawnedPids` shape.
|
|
290
|
+
*
|
|
291
|
+
* Returns a fresh Map every call: the occupancy detector holds it for the
|
|
292
|
+
* duration of a scan, and a live view could change under it mid-scan.
|
|
293
|
+
*
|
|
294
|
+
* Never throws. The caller wraps it anyway (`threadOccupancy` turns a
|
|
295
|
+
* throwing probe into `probe_failed`), and that wrapping is the reason this
|
|
296
|
+
* must not rely on it: `probe_failed` is a scan-wide verdict, so one bad
|
|
297
|
+
* clock read here would suppress the Claude registry evidence too. Returning
|
|
298
|
+
* an empty ledger keeps the rest of the scan intact and still denies every
|
|
299
|
+
* self-ownership claim.
|
|
300
|
+
*/
|
|
301
|
+
snapshot(): ReadonlyMap<number, number> {
|
|
302
|
+
try {
|
|
303
|
+
this.prune(this.clock())
|
|
304
|
+
const out = new Map<number, number>()
|
|
305
|
+
for (const [pid, entry] of this.entries) out.set(pid, entry.startMs)
|
|
306
|
+
return out
|
|
307
|
+
} catch {
|
|
308
|
+
this.snapshotFailures++
|
|
309
|
+
return new Map<number, number>()
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Diagnostics only. Never feed this back into an attach decision. */
|
|
314
|
+
stats(): SpawnLedgerStats {
|
|
315
|
+
let tracked = this.entries.size
|
|
316
|
+
try {
|
|
317
|
+
this.prune(this.clock())
|
|
318
|
+
tracked = this.entries.size
|
|
319
|
+
} catch {
|
|
320
|
+
this.snapshotFailures++
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
tracked,
|
|
324
|
+
recorded: this.recordedCount,
|
|
325
|
+
released: this.releasedCount,
|
|
326
|
+
rejected: this.rejectedCount,
|
|
327
|
+
lastRejection: this.lastRejection,
|
|
328
|
+
evictedExpired: this.evictedExpired,
|
|
329
|
+
evictedCapacity: this.evictedCapacity,
|
|
330
|
+
snapshotFailures: this.snapshotFailures,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Drop every claim. Used by tests and by a deliberate "we own nothing" reset. */
|
|
335
|
+
clear(): void {
|
|
336
|
+
this.entries.clear()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private reject(outcome: SpawnRecordOutcome): SpawnRecordOutcome {
|
|
340
|
+
this.rejectedCount++
|
|
341
|
+
this.lastRejection = outcome
|
|
342
|
+
return outcome
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private prune(now: number): void {
|
|
346
|
+
for (const [pid, entry] of this.entries) {
|
|
347
|
+
const age = now - entry.recordedAt
|
|
348
|
+
// A NEGATIVE age means the clock moved backwards, so the entry's age is
|
|
349
|
+
// unknowable. Unknowable resolves to expired, like every other unknown
|
|
350
|
+
// in this file — the alternative is an entry that outlives its bound by
|
|
351
|
+
// however far the clock jumped.
|
|
352
|
+
if (age >= this.ttlMs || age < 0) {
|
|
353
|
+
this.entries.delete(pid)
|
|
354
|
+
this.evictedExpired++
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private evictToCapacity(): void {
|
|
360
|
+
while (this.entries.size >= this.maxEntries) {
|
|
361
|
+
let oldestPid: number | null = null
|
|
362
|
+
let oldestAt = Number.POSITIVE_INFINITY
|
|
363
|
+
for (const [pid, entry] of this.entries) {
|
|
364
|
+
if (entry.recordedAt < oldestAt) {
|
|
365
|
+
oldestAt = entry.recordedAt
|
|
366
|
+
oldestPid = pid
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (oldestPid === null) return
|
|
370
|
+
this.entries.delete(oldestPid)
|
|
371
|
+
this.evictedCapacity++
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Process-wide ledger. One server, one set of children. */
|
|
377
|
+
export const cosSpawnLedger = new CosSpawnLedger()
|
|
378
|
+
|
|
379
|
+
/** Record a provider child COS just spawned. See `CosSpawnLedger.record`. */
|
|
380
|
+
export function recordCosSpawn(pid: unknown, startMs: unknown): SpawnRecordOutcome {
|
|
381
|
+
return cosSpawnLedger.record(pid, startMs)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Release a provider child that has exited. */
|
|
385
|
+
export function releaseCosSpawn(pid: unknown): boolean {
|
|
386
|
+
return cosSpawnLedger.release(pid)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The `OccupancyProbes.cosSpawnedPids` implementation. Named to match the probe
|
|
391
|
+
* field so wiring is `{ cosSpawnedPids }` with nothing in between to get wrong.
|
|
392
|
+
*/
|
|
393
|
+
export function cosSpawnedPids(): ReadonlyMap<number, number> {
|
|
394
|
+
return cosSpawnLedger.snapshot()
|
|
395
|
+
}
|