@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource limits for the child process trees that run model-driven code on a replica
|
|
3
|
+
* (docs/rfc/aaas-rfc.md §4.8, threat S-17).
|
|
4
|
+
*
|
|
5
|
+
* The tenant boundary of the state layer says nothing about CPU and memory: every tenant on a replica shares them,
|
|
6
|
+
* so one runaway planner child could take the replica, and every tenant on it, down with it. This module bounds
|
|
7
|
+
* what one child tree can take before the operating system's OOM killer decides for us. It is a ceiling for a
|
|
8
|
+
* runaway, not a budget and not isolation: a tree that stays under its limits still shares the machine.
|
|
9
|
+
*
|
|
10
|
+
* The governor samples the process table (`ps`), finds the tree that starts at a watched root, and kills the
|
|
11
|
+
* whole tree the moment it is over a limit. The root is identified by its parent as well as by its pid, so a pid
|
|
12
|
+
* that the operating system has reused for an unrelated process is never sampled and never killed. Sampling is
|
|
13
|
+
* read-only, one `ps` per tick for every watched tree, and a failed sample kills nothing.
|
|
14
|
+
*
|
|
15
|
+
* Linux places the worker in a user-systemd scope and Darwin in its own process group, so neither a group id nor a
|
|
16
|
+
* cgroup is assumed here: the tree is the root and its descendants by parent pid, which both platforms report.
|
|
17
|
+
* A child that deliberately re-parents itself away leaves the tree; the planner children cannot start processes
|
|
18
|
+
* (their shell, file and subprocess plugins are disabled), and the provider-owned range cleanup of
|
|
19
|
+
* `dsh-subprocess-local` remains the primary teardown. This is the second line.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile } from 'node:child_process'
|
|
23
|
+
|
|
24
|
+
export interface TreeLimits {
|
|
25
|
+
/**
|
|
26
|
+
* Resident set of the whole tree in bytes. Pages that processes share are counted once per process, so this
|
|
27
|
+
* overstates: set it as a ceiling for a runaway.
|
|
28
|
+
*/
|
|
29
|
+
readonly maxRssBytes?: number
|
|
30
|
+
/** CPU time consumed by the tree's live processes, in seconds, as the process table reports it (one-second resolution on Linux). */
|
|
31
|
+
readonly maxCpuSeconds?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TreeBreach {
|
|
35
|
+
/**
|
|
36
|
+
* `blind` is the sampler failing closed: the tree could not be seen for `limit` ticks in a row (the table could not be
|
|
37
|
+
* read, timed out, or did not show the watched root), so nothing about its limits can be said and the owner is told.
|
|
38
|
+
*/
|
|
39
|
+
readonly kind: 'memory' | 'cpu' | 'blind'
|
|
40
|
+
readonly limit: number
|
|
41
|
+
readonly observed: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProcessRow {
|
|
45
|
+
readonly pid: number
|
|
46
|
+
readonly ppid: number
|
|
47
|
+
readonly rssBytes: number
|
|
48
|
+
readonly cpuSeconds: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ProcessTable = () => Promise<readonly ProcessRow[]>
|
|
52
|
+
|
|
53
|
+
/** A tree to watch: the root and the process that must be its parent (this process, for a child it spawned). */
|
|
54
|
+
export interface TreeTarget {
|
|
55
|
+
readonly rootPid: number
|
|
56
|
+
readonly parentPid: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TreeSample {
|
|
60
|
+
readonly pids: readonly number[]
|
|
61
|
+
readonly rssBytes: number
|
|
62
|
+
readonly cpuSeconds: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The process table cannot be read or does not show this process: the sampler would govern nothing. */
|
|
66
|
+
export class ProcessTableUnavailableError extends Error {
|
|
67
|
+
constructor(cause?: unknown) {
|
|
68
|
+
super('the process table cannot be read, so planner process trees cannot be governed', { cause })
|
|
69
|
+
this.name = 'ProcessTableUnavailableError'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProcessTreeGovernorOptions {
|
|
74
|
+
/** Time between samples. Default 1000. */
|
|
75
|
+
readonly intervalMs?: number
|
|
76
|
+
/** Ticks in a row on which a watched tree cannot be seen before it is treated as a breach. Default 10. */
|
|
77
|
+
readonly maxBlindTicks?: number
|
|
78
|
+
/** Longest a read of the process table may take before the tick counts it as a failure. Default 5000. */
|
|
79
|
+
readonly tableTimeoutMs?: number
|
|
80
|
+
/** The process table. Default: `ps`. Injected by the suites. */
|
|
81
|
+
readonly table?: ProcessTable
|
|
82
|
+
/** Kills the sampled processes. Default: SIGKILL each pid. Injected by the suites. */
|
|
83
|
+
readonly kill?: (pids: readonly number[]) => void
|
|
84
|
+
/** Receives a failed sample or a throwing breach handler. A failure here never stops the governor. */
|
|
85
|
+
readonly onFault?: (error: unknown) => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DEFAULT_INTERVAL_MS = 1_000
|
|
89
|
+
const DEFAULT_MAX_BLIND_TICKS = 10
|
|
90
|
+
const DEFAULT_TABLE_TIMEOUT_MS = 5_000
|
|
91
|
+
const PS_TIMEOUT_MS = 3_000
|
|
92
|
+
const PS_MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
|
93
|
+
|
|
94
|
+
/** `[DD-][HH:]MM:SS[.ss]`: Linux prints `HH:MM:SS` and `DD-HH:MM:SS`, Darwin `M:SS.ss` and `H:MM:SS.ss`. NaN for anything else. */
|
|
95
|
+
export function parseCpuSeconds(text: string): number {
|
|
96
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(text.trim())
|
|
97
|
+
if (!match) return Number.NaN
|
|
98
|
+
const [, days, hours, minutes, seconds] = match
|
|
99
|
+
return (Number(days ?? 0) * 24 + Number(hours ?? 0)) * 3_600 + Number(minutes) * 60 + Number(seconds)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Parses the output of `ps` with `PS_ARGS` (rss in KiB). Lines that do not parse are dropped, never guessed. */
|
|
103
|
+
export function parsePsTable(stdout: string): ProcessRow[] {
|
|
104
|
+
const rows: ProcessRow[] = []
|
|
105
|
+
for (const line of stdout.split('\n')) {
|
|
106
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line)
|
|
107
|
+
if (!match) continue
|
|
108
|
+
const cpuSeconds = parseCpuSeconds(match[4]!)
|
|
109
|
+
if (Number.isNaN(cpuSeconds)) continue
|
|
110
|
+
rows.push({ pid: Number(match[1]), ppid: Number(match[2]), rssBytes: Number(match[3]) * 1024, cpuSeconds })
|
|
111
|
+
}
|
|
112
|
+
return rows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `-A` selects every process on procps (Linux) and on BSD (Darwin) alike; the BSD-style `-ax` that the cleanup diagnostics
|
|
117
|
+
* use on Darwin makes procps warn on stderr. `pid=` and the like suppress the header. `rss` is in KiB on both.
|
|
118
|
+
*/
|
|
119
|
+
export const PS_ARGS: readonly string[] = ['-A', '-o', 'pid=,ppid=,rss=,time=']
|
|
120
|
+
|
|
121
|
+
export function readPsTable(): Promise<readonly ProcessRow[]> {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
execFile('/bin/ps', [...PS_ARGS], {
|
|
124
|
+
encoding: 'utf8', timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_BUFFER_BYTES,
|
|
125
|
+
env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' },
|
|
126
|
+
}, (error, stdout) => (error ? reject(error) : resolve(parsePsTable(stdout))))
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The root and its descendants, or `null` when the root is not there or is not a child of the expected parent.
|
|
132
|
+
* Pure: the suites feed crafted tables.
|
|
133
|
+
*/
|
|
134
|
+
export function treeOf(rows: readonly ProcessRow[], target: TreeTarget): TreeSample | null {
|
|
135
|
+
const byPid = new Map<number, ProcessRow>()
|
|
136
|
+
const children = new Map<number, ProcessRow[]>()
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
byPid.set(row.pid, row)
|
|
139
|
+
const siblings = children.get(row.ppid)
|
|
140
|
+
if (siblings) siblings.push(row)
|
|
141
|
+
else children.set(row.ppid, [row])
|
|
142
|
+
}
|
|
143
|
+
const root = byPid.get(target.rootPid)
|
|
144
|
+
if (!root || root.ppid !== target.parentPid) return null
|
|
145
|
+
const pids: number[] = []
|
|
146
|
+
let rssBytes = 0
|
|
147
|
+
let cpuSeconds = 0
|
|
148
|
+
const seen = new Set<number>()
|
|
149
|
+
const queue: ProcessRow[] = [root]
|
|
150
|
+
for (let row = queue.shift(); row; row = queue.shift()) {
|
|
151
|
+
if (seen.has(row.pid)) continue
|
|
152
|
+
seen.add(row.pid)
|
|
153
|
+
pids.push(row.pid)
|
|
154
|
+
rssBytes += row.rssBytes
|
|
155
|
+
cpuSeconds += row.cpuSeconds
|
|
156
|
+
for (const child of children.get(row.pid) ?? []) queue.push(child)
|
|
157
|
+
}
|
|
158
|
+
return { pids, rssBytes, cpuSeconds }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
162
|
+
return new Promise<T>((resolve, reject) => {
|
|
163
|
+
const timer = setTimeout(() => reject(new Error('the process table did not answer in time')), ms)
|
|
164
|
+
promise.then(
|
|
165
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
166
|
+
(error: unknown) => { clearTimeout(timer); reject(error) },
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function breachOf(sample: TreeSample, limits: TreeLimits): TreeBreach | null {
|
|
172
|
+
if (limits.maxRssBytes !== undefined && sample.rssBytes > limits.maxRssBytes) {
|
|
173
|
+
return { kind: 'memory', limit: limits.maxRssBytes, observed: sample.rssBytes }
|
|
174
|
+
}
|
|
175
|
+
if (limits.maxCpuSeconds !== undefined && sample.cpuSeconds > limits.maxCpuSeconds) {
|
|
176
|
+
return { kind: 'cpu', limit: limits.maxCpuSeconds, observed: sample.cpuSeconds }
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function assertPositive(name: string, value: number | undefined): void {
|
|
182
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive number`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function killEach(pids: readonly number[]): void {
|
|
186
|
+
for (const pid of pids) {
|
|
187
|
+
try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface Watch {
|
|
192
|
+
readonly target: TreeTarget
|
|
193
|
+
readonly limits: TreeLimits
|
|
194
|
+
readonly onBreach: (breach: TreeBreach) => void
|
|
195
|
+
/** Ticks in a row on which this tree could not be seen. */
|
|
196
|
+
blind: number
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export class ProcessTreeGovernor {
|
|
200
|
+
private readonly intervalMs: number
|
|
201
|
+
private readonly maxBlindTicks: number
|
|
202
|
+
private readonly tableTimeoutMs: number
|
|
203
|
+
private readonly table: ProcessTable
|
|
204
|
+
private readonly kill: (pids: readonly number[]) => void
|
|
205
|
+
private readonly onFault: ((error: unknown) => void) | undefined
|
|
206
|
+
private readonly watches = new Map<number, Watch>()
|
|
207
|
+
private nextId = 1
|
|
208
|
+
private timer: NodeJS.Timeout | undefined
|
|
209
|
+
private ticking = false
|
|
210
|
+
private sampleFailures = 0
|
|
211
|
+
|
|
212
|
+
constructor(options: ProcessTreeGovernorOptions = {}) {
|
|
213
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
|
|
214
|
+
assertPositive('intervalMs', this.intervalMs)
|
|
215
|
+
this.maxBlindTicks = options.maxBlindTicks ?? DEFAULT_MAX_BLIND_TICKS
|
|
216
|
+
assertPositive('maxBlindTicks', this.maxBlindTicks)
|
|
217
|
+
this.tableTimeoutMs = options.tableTimeoutMs ?? DEFAULT_TABLE_TIMEOUT_MS
|
|
218
|
+
assertPositive('tableTimeoutMs', this.tableTimeoutMs)
|
|
219
|
+
this.table = options.table ?? readPsTable
|
|
220
|
+
this.kill = options.kill ?? killEach
|
|
221
|
+
this.onFault = options.onFault
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Trees under watch now. */
|
|
225
|
+
get size(): number {
|
|
226
|
+
return this.watches.size
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Samples that failed since construction. A failed sample kills nothing. */
|
|
230
|
+
get failedSamples(): number {
|
|
231
|
+
return this.sampleFailures
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Watches one tree. `onBreach` runs once, before the tree is killed, so the owner can record why. The returned
|
|
236
|
+
* function stops the watch and is safe to call after a breach. With no limit given nothing is watched.
|
|
237
|
+
*/
|
|
238
|
+
watch(target: TreeTarget, limits: TreeLimits, onBreach: (breach: TreeBreach) => void): () => void {
|
|
239
|
+
if (!Number.isSafeInteger(target.rootPid) || target.rootPid <= 0) throw new RangeError('rootPid must be a positive integer')
|
|
240
|
+
if (!Number.isSafeInteger(target.parentPid) || target.parentPid <= 0) throw new RangeError('parentPid must be a positive integer')
|
|
241
|
+
assertPositive('maxRssBytes', limits.maxRssBytes)
|
|
242
|
+
assertPositive('maxCpuSeconds', limits.maxCpuSeconds)
|
|
243
|
+
if (limits.maxRssBytes === undefined && limits.maxCpuSeconds === undefined) return () => undefined
|
|
244
|
+
const id = this.nextId++
|
|
245
|
+
this.watches.set(id, { target, limits, onBreach, blind: 0 })
|
|
246
|
+
this.startTimer()
|
|
247
|
+
return () => {
|
|
248
|
+
this.watches.delete(id)
|
|
249
|
+
if (this.watches.size === 0) this.stopTimer()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Proves the sampler can see: the table reads, and it shows this very process. A service that cannot see its own
|
|
255
|
+
* process table would run its children ungoverned and silently (for example where `ps` is a stripped-down build),
|
|
256
|
+
* so a deployment checks this once before the first tree starts and refuses to run children without it.
|
|
257
|
+
*/
|
|
258
|
+
async probe(): Promise<void> {
|
|
259
|
+
let rows: readonly ProcessRow[]
|
|
260
|
+
try {
|
|
261
|
+
rows = await withTimeout(this.table(), this.tableTimeoutMs)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
throw new ProcessTableUnavailableError(error)
|
|
264
|
+
}
|
|
265
|
+
const self = rows.find((row) => row.pid === process.pid)
|
|
266
|
+
if (!self || self.rssBytes <= 0) throw new ProcessTableUnavailableError()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** One sampling pass. The timer calls it; the suites call it directly to stay deterministic. */
|
|
270
|
+
async tick(): Promise<void> {
|
|
271
|
+
if (this.ticking || this.watches.size === 0) return
|
|
272
|
+
this.ticking = true
|
|
273
|
+
try {
|
|
274
|
+
let rows: readonly ProcessRow[] | null = null
|
|
275
|
+
try {
|
|
276
|
+
rows = await withTimeout(this.table(), this.tableTimeoutMs)
|
|
277
|
+
} catch (error) {
|
|
278
|
+
this.sampleFailures += 1
|
|
279
|
+
this.report(error)
|
|
280
|
+
}
|
|
281
|
+
for (const [id, watch] of [...this.watches]) {
|
|
282
|
+
const sample = rows ? treeOf(rows, watch.target) : null
|
|
283
|
+
if (!sample) {
|
|
284
|
+
// A tree that cannot be seen is not a tree within its limits. A few such ticks happen (a worker exiting between
|
|
285
|
+
// two samples); a run of them means the sampler governs nothing, and failing open would hide it.
|
|
286
|
+
watch.blind += 1
|
|
287
|
+
if (watch.blind >= this.maxBlindTicks) this.end(id, watch, { kind: 'blind', limit: this.maxBlindTicks, observed: watch.blind }, [])
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
watch.blind = 0
|
|
291
|
+
const breach = breachOf(sample, watch.limits)
|
|
292
|
+
if (breach) this.end(id, watch, breach, sample.pids)
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
this.ticking = false
|
|
296
|
+
if (this.watches.size === 0) this.stopTimer()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
close(): void {
|
|
301
|
+
this.watches.clear()
|
|
302
|
+
this.stopTimer()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The watch is over: the owner is told why, and whatever was seen of the tree is killed. */
|
|
306
|
+
private end(id: number, watch: Watch, breach: TreeBreach, pids: readonly number[]): void {
|
|
307
|
+
this.watches.delete(id)
|
|
308
|
+
try {
|
|
309
|
+
watch.onBreach(breach)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
this.report(error)
|
|
312
|
+
} finally {
|
|
313
|
+
this.kill(pids)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private report(error: unknown): void {
|
|
318
|
+
try { this.onFault?.(error) } catch { /* reporting must never stop the governor */ }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private startTimer(): void {
|
|
322
|
+
if (this.timer) return
|
|
323
|
+
this.timer = setInterval(() => { this.tick().catch((error: unknown) => this.report(error)) }, this.intervalMs)
|
|
324
|
+
// The governor must not keep the process alive: a watched child cannot outlive its owner's own shutdown.
|
|
325
|
+
this.timer.unref()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private stopTimer(): void {
|
|
329
|
+
if (!this.timer) return
|
|
330
|
+
clearInterval(this.timer)
|
|
331
|
+
this.timer = undefined
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let shared: ProcessTreeGovernor | undefined
|
|
336
|
+
|
|
337
|
+
/** The governor every managed port shares by default: one `ps` per tick, however many trees are watched. */
|
|
338
|
+
export function sharedProcessTreeGovernor(): ProcessTreeGovernor {
|
|
339
|
+
shared ??= new ProcessTreeGovernor({
|
|
340
|
+
onFault: (error) => { console.error('[process-governor] fault:', error instanceof Error ? error.message : String(error)) },
|
|
341
|
+
})
|
|
342
|
+
return shared
|
|
343
|
+
}
|