@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.
Files changed (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Compute isolation of the planner children (AaaS, docs/rfc/aaas-rfc.md §4.8; threat S-17).
3
+ *
4
+ * The tenant boundary of the state layer says nothing about CPU and memory, and every tenant on a replica shares
5
+ * them. These checks prove that a runaway planner child is stopped by a limit this process enforces, is reported as a
6
+ * typed non-retryable failure, takes nothing else down, and that a port built without limits is unchanged.
7
+ *
8
+ * The governor checks are deterministic (injected process table and kill). The port checks use real processes: a
9
+ * fixture worker that leaks off-heap memory, leaks in a grandchild, spins, exhausts its V8 heap, or writes a line
10
+ * that never ends. Every runaway mode bounds itself, so a broken guard fails a check instead of the machine.
11
+ *
12
+ * G governor: time formats, process-table parsing, tree membership, pid reuse, breach rules, one breach per watch,
13
+ * isolation between watches, failed samples, overlapping ticks, validation, the timer
14
+ * P ports (real processes): memory, memory in a grandchild, CPU, V8 heap, unbounded line, neighbours unaffected,
15
+ * a port without limits unchanged
16
+ * D planner: a resource kill is a non-retryable PLANNER_FAILED with its own metric; the tenant provider's defaults
17
+ * R replica capacity: a replica holds no more tenants than it was configured to (each costs file descriptors), and
18
+ * past that a tenant is refused before its lease or any file exists
19
+ *
20
+ * Run: cd ts && npx tsx scripts/tenant-compute-isolation-tests.ts
21
+ */
22
+ import assert from 'node:assert/strict';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { setTimeout as sleep } from 'node:timers/promises';
29
+ import { createDshEmbeddedBookingPlanner, createRealRunPort, } from '../src/booking-surface/dsh-planner.js';
30
+ import { createManagedDshRunPort, ManagedDshResourceLimitError, ManagedDshRunPort } from '../src/booking-surface/managed-dsh-run-port.js';
31
+ import { assertPlannerLimitsEnforceable, createDshTenantPlannerProvider, DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS } from '../src/booking-surface/startup.js';
32
+ import { tenantRejection } from '../src/booking-surface/tenancy.js';
33
+ import { DirDurableStore } from '../src/tenant-state/durable-store.js';
34
+ import { ReplicaAtCapacityError } from '../src/tenant-state/errors.js';
35
+ import { deriveLedgerTenantId } from '../src/tenant-state/ids.js';
36
+ import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
37
+ import { TenantRouter } from '../src/tenant-state/tenant-router.js';
38
+ import { parseCpuSeconds, parsePsTable, ProcessTableUnavailableError, ProcessTreeGovernor, sharedProcessTreeGovernor, treeOf, } from '../src/tenant-seam/process-governor.js';
39
+ const MIB = 1024 * 1024;
40
+ // Every managed port registers a process-exit hook; this suite builds more of them than a service ever holds at once.
41
+ process.setMaxListeners(100);
42
+ const FIXTURE = fileURLToPath(new URL('./fixtures/tenant-runaway-worker-fixture.mjs', import.meta.url));
43
+ let passed = 0;
44
+ const only = process.env.GOTRY_TEST_ONLY;
45
+ async function check(label, assertion) {
46
+ if (only && !label.startsWith(only))
47
+ return;
48
+ await assertion();
49
+ passed += 1;
50
+ console.log(` ok - ${label}`);
51
+ }
52
+ function row(pid, ppid, rssMiB, cpuSeconds = 0) {
53
+ return { pid, ppid, rssBytes: rssMiB * MIB, cpuSeconds };
54
+ }
55
+ const ME = 1;
56
+ function harness(table, extra = {}) {
57
+ const killed = [];
58
+ const faults = [];
59
+ const order = [];
60
+ const governor = new ProcessTreeGovernor({
61
+ intervalMs: 3_600_000,
62
+ ...extra,
63
+ table: async () => table(),
64
+ kill: (pids) => { order.push('killed'); killed.push([...pids].sort((a, b) => a - b)); },
65
+ onFault: (error) => { faults.push(error); },
66
+ });
67
+ return { governor, killed, faults, order };
68
+ }
69
+ function alive(pid) {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ async function waitFor(condition, timeoutMs, label) {
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (!condition()) {
81
+ if (Date.now() >= deadline)
82
+ throw new Error(`timed out waiting for: ${label}`);
83
+ await sleep(20);
84
+ }
85
+ }
86
+ /** A call that must come back: when it does not, the check fails with this message instead of the suite stalling. */
87
+ async function within(promise, ms, what) {
88
+ let timer;
89
+ try {
90
+ return await Promise.race([promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`${what} did not come back within ${ms} ms`)), ms); })]);
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+ async function rejection(promise) {
97
+ try {
98
+ await promise;
99
+ }
100
+ catch (error) {
101
+ return error;
102
+ }
103
+ throw new assert.AssertionError({ message: 'expected the promise to reject' });
104
+ }
105
+ // ---- G: governor ------------------------------------------------------------------------------
106
+ console.log('G governor');
107
+ await check('G1 CPU time parses in the Linux and Darwin formats and rejects anything else', () => {
108
+ assert.equal(parseCpuSeconds('00:00:05'), 5);
109
+ assert.equal(parseCpuSeconds('01:02:03'), 3_723);
110
+ assert.equal(parseCpuSeconds('1-02:03:04'), 86_400 + 7_384);
111
+ assert.equal(parseCpuSeconds('0:00.05'), 0.05);
112
+ assert.equal(parseCpuSeconds('12:34.56'), 754.56);
113
+ assert.equal(parseCpuSeconds('1:02:03.45'), 3_723.45);
114
+ for (const bad of ['', 'abc', '12', '1:2:3:4', '-1:00', '1-', ':30'])
115
+ assert.ok(Number.isNaN(parseCpuSeconds(bad)), `"${bad}" is not a CPU time`);
116
+ });
117
+ await check('G2 the process table is parsed in KiB, and a line that does not parse is dropped, not guessed', () => {
118
+ const rows = parsePsTable(' 100 1 2048 00:00:07\n 101 100 512 0:01.50\n\nnot a row\n 102 100 xx 00:00:01\n 103 100 64 garbage\n');
119
+ assert.deepEqual(rows, [
120
+ { pid: 100, ppid: 1, rssBytes: 2_048 * 1024, cpuSeconds: 7 },
121
+ { pid: 101, ppid: 100, rssBytes: 512 * 1024, cpuSeconds: 1.5 },
122
+ ]);
123
+ });
124
+ await check('G3 a tree is the root and its descendants, summed; siblings and strangers are not in it', () => {
125
+ const sample = treeOf([row(100, ME, 10, 1), row(101, 100, 20, 2), row(102, 101, 30, 3), row(103, 100, 5, 1), row(200, ME, 500, 50), row(201, 200, 500, 50)], { rootPid: 100, parentPid: ME });
126
+ assert.ok(sample);
127
+ assert.deepEqual([...sample.pids].sort((a, b) => a - b), [100, 101, 102, 103]);
128
+ assert.equal(sample.rssBytes, 65 * MIB);
129
+ assert.equal(sample.cpuSeconds, 7);
130
+ });
131
+ await check('G4 a reused pid is not the root: a different parent means the tree is not sampled and nothing is killed', async () => {
132
+ assert.equal(treeOf([row(100, 999, 4_000, 4_000)], { rootPid: 100, parentPid: ME }), null);
133
+ const { governor, killed } = harness(() => [row(100, 999, 4_000, 4_000)]);
134
+ let breaches = 0;
135
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB, maxCpuSeconds: 1 }, () => { breaches += 1; });
136
+ await governor.tick();
137
+ assert.equal(breaches, 0);
138
+ assert.deepEqual(killed, []);
139
+ assert.equal(treeOf([], { rootPid: 100, parentPid: ME }), null, 'a root that is gone has no tree');
140
+ });
141
+ await check('G5 memory: over the limit is a breach, at the limit is not; the whole tree is killed and the owner told why first', async () => {
142
+ let table = [row(100, ME, 100), row(101, 100, 90), row(102, 101, 10)];
143
+ const { governor, killed, order } = harness(() => table);
144
+ const breaches = [];
145
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 200 * MIB }, (breach) => { breaches.push(breach); order.push('told'); });
146
+ await governor.tick();
147
+ assert.equal(breaches.length, 0, 'exactly at the limit is within it');
148
+ table = [row(100, ME, 100), row(101, 100, 90), row(102, 101, 10), row(103, 102, 1)];
149
+ await governor.tick();
150
+ assert.deepEqual(breaches, [{ kind: 'memory', limit: 200 * MIB, observed: 201 * MIB }]);
151
+ assert.deepEqual(killed, [[100, 101, 102, 103]]);
152
+ assert.deepEqual(order, ['told', 'killed']);
153
+ });
154
+ await check('G6 CPU: over the limit is a breach, and memory is reported first when both are over', async () => {
155
+ const cpuOnly = harness(() => [row(100, ME, 10, 31), row(101, 100, 10, 30)]);
156
+ const seen = [];
157
+ cpuOnly.governor.watch({ rootPid: 100, parentPid: ME }, { maxCpuSeconds: 60 }, (breach) => seen.push(breach));
158
+ await cpuOnly.governor.tick();
159
+ assert.deepEqual(seen, [{ kind: 'cpu', limit: 60, observed: 61 }]);
160
+ const both = harness(() => [row(100, ME, 500, 500)]);
161
+ const bothSeen = [];
162
+ both.governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB, maxCpuSeconds: 1 }, (breach) => bothSeen.push(breach));
163
+ await both.governor.tick();
164
+ assert.deepEqual(bothSeen.map((breach) => breach.kind), ['memory']);
165
+ });
166
+ await check('G7 a watch breaches once: later ticks neither tell the owner again nor kill again', async () => {
167
+ const { governor, killed } = harness(() => [row(100, ME, 900)]);
168
+ let told = 0;
169
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 100 * MIB }, () => { told += 1; });
170
+ await governor.tick();
171
+ await governor.tick();
172
+ await governor.tick();
173
+ assert.equal(told, 1);
174
+ assert.equal(killed.length, 1);
175
+ assert.equal(governor.size, 0);
176
+ });
177
+ await check('G8 watches are independent: only the tree over its limit is killed, and the neighbour is not told anything', async () => {
178
+ const { governor, killed } = harness(() => [row(100, ME, 900), row(101, 100, 1), row(200, ME, 50), row(201, 200, 1)]);
179
+ const told = [];
180
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 100 * MIB }, () => told.push('hot'));
181
+ governor.watch({ rootPid: 200, parentPid: ME }, { maxRssBytes: 100 * MIB }, () => told.push('calm'));
182
+ await governor.tick();
183
+ assert.deepEqual(told, ['hot']);
184
+ assert.deepEqual(killed, [[100, 101]]);
185
+ assert.equal(governor.size, 1, 'the calm tree is still watched');
186
+ });
187
+ await check('G9 unwatch stops a watch for good, and is safe to call twice or after a breach', async () => {
188
+ const { governor, killed } = harness(() => [row(100, ME, 900)]);
189
+ const unwatch = governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB }, () => undefined);
190
+ unwatch();
191
+ unwatch();
192
+ await governor.tick();
193
+ assert.deepEqual(killed, []);
194
+ const afterBreach = harness(() => [row(100, ME, 900)]);
195
+ const stop = afterBreach.governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB }, () => undefined);
196
+ await afterBreach.governor.tick();
197
+ stop();
198
+ assert.equal(afterBreach.governor.size, 0);
199
+ });
200
+ await check('G10 a failed sample kills nothing, is counted and reported, and the next sample works', async () => {
201
+ let fail = true;
202
+ const { governor, killed, faults } = harness(() => {
203
+ if (fail)
204
+ throw new Error('ps unavailable');
205
+ return [row(100, ME, 900)];
206
+ });
207
+ let told = 0;
208
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB }, () => { told += 1; });
209
+ await governor.tick();
210
+ assert.equal(governor.failedSamples, 1);
211
+ assert.equal(faults.length, 1);
212
+ assert.deepEqual(killed, []);
213
+ assert.equal(told, 0);
214
+ fail = false;
215
+ await governor.tick();
216
+ assert.equal(told, 1);
217
+ assert.equal(killed.length, 1);
218
+ });
219
+ await check('G11 ticks never overlap: a slow table is read once however many ticks ask', async () => {
220
+ let reads = 0;
221
+ const { governor } = harness(async () => { reads += 1; await sleep(60); return [row(100, ME, 1)]; });
222
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 100 * MIB }, () => undefined);
223
+ await Promise.all([governor.tick(), governor.tick(), governor.tick()]);
224
+ assert.equal(reads, 1);
225
+ });
226
+ await check('G12 a breach handler that throws is reported, and the tree is killed all the same', async () => {
227
+ const { governor, killed, faults } = harness(() => [row(100, ME, 900)]);
228
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB }, () => { throw new Error('handler bug'); });
229
+ await governor.tick();
230
+ assert.deepEqual(killed, [[100]]);
231
+ assert.equal(faults.length, 1);
232
+ });
233
+ await check('G13 limits are validated; a watch with no limit watches nothing and starts no timer', () => {
234
+ const { governor } = harness(() => []);
235
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
236
+ assert.throws(() => governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: bad }, () => undefined), RangeError);
237
+ assert.throws(() => governor.watch({ rootPid: 100, parentPid: ME }, { maxCpuSeconds: bad }, () => undefined), RangeError);
238
+ }
239
+ assert.throws(() => governor.watch({ rootPid: 0, parentPid: ME }, { maxRssBytes: MIB }, () => undefined), RangeError);
240
+ assert.throws(() => governor.watch({ rootPid: 100, parentPid: 1.5 }, { maxRssBytes: MIB }, () => undefined), RangeError);
241
+ const none = governor.watch({ rootPid: 100, parentPid: ME }, {}, () => undefined);
242
+ assert.equal(governor.size, 0, 'a watch with no limit is not registered at all');
243
+ none();
244
+ assert.throws(() => new ProcessTreeGovernor({ intervalMs: 0 }), RangeError);
245
+ });
246
+ await check('G14 the timer drives sampling on its own, and close() stops it', async () => {
247
+ let reads = 0;
248
+ const killed = [];
249
+ const governor = new ProcessTreeGovernor({ intervalMs: 15, table: async () => { reads += 1; return [row(100, ME, 900)]; }, kill: (pids) => { killed.push([...pids]); } });
250
+ let told = 0;
251
+ governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: MIB }, () => { told += 1; });
252
+ await waitFor(() => told === 1, 2_000, 'the timer to sample');
253
+ assert.deepEqual(killed, [[100]]);
254
+ const readsAtBreach = reads;
255
+ await sleep(80);
256
+ assert.equal(reads, readsAtBreach, 'with nothing left to watch the timer stopped');
257
+ governor.close();
258
+ });
259
+ await check('G15 a governor that cannot see the process table says so: it must read, and it must show this very process', async () => {
260
+ const seeing = harness(() => [row(process.pid, 1, 50), row(100, process.pid, 5)]);
261
+ await seeing.governor.probe();
262
+ const failing = harness(() => { throw new Error('ps: command not found'); });
263
+ await assert.rejects(failing.governor.probe(), (error) => error instanceof ProcessTableUnavailableError && error.cause.message === 'ps: command not found');
264
+ const blind = harness(() => [row(100, 1, 50)]);
265
+ await assert.rejects(blind.governor.probe(), ProcessTableUnavailableError);
266
+ const empty = harness(() => [row(process.pid, 1, 0)]);
267
+ await assert.rejects(empty.governor.probe(), ProcessTableUnavailableError);
268
+ });
269
+ await check('G16 a sampler that cannot see fails closed: a watched root missing for N ticks in a row, or a table that fails or hangs, is a breach of its own kind', async () => {
270
+ let visible = false;
271
+ const missing = harness(() => (visible ? [row(100, ME, 1)] : [row(200, ME, 1)]), { maxBlindTicks: 3 });
272
+ const told = [];
273
+ missing.governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 100 * MIB }, (breach) => told.push(breach));
274
+ await missing.governor.tick();
275
+ await missing.governor.tick();
276
+ visible = true;
277
+ await missing.governor.tick();
278
+ visible = false;
279
+ await missing.governor.tick();
280
+ await missing.governor.tick();
281
+ assert.deepEqual(told, [], 'two blind ticks, a good one, then two more: the count starts over');
282
+ await missing.governor.tick();
283
+ assert.deepEqual(told, [{ kind: 'blind', limit: 3, observed: 3 }]);
284
+ assert.equal(missing.governor.size, 0, 'the watch is over once it has failed');
285
+ assert.deepEqual(missing.killed, [[]], 'nothing could be seen, so nothing is killed from here: the owner is told and tears its tree down');
286
+ let failing = true;
287
+ const broken = harness(() => { if (failing)
288
+ throw new Error('ps failed'); return [row(100, ME, 1)]; }, { maxBlindTicks: 2 });
289
+ const brokenTold = [];
290
+ broken.governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 100 * MIB }, (breach) => brokenTold.push(breach.kind));
291
+ await broken.governor.tick();
292
+ failing = false;
293
+ await broken.governor.tick();
294
+ failing = true;
295
+ await broken.governor.tick();
296
+ assert.deepEqual(brokenTold, [], 'a read that works in between resets the count');
297
+ await broken.governor.tick();
298
+ assert.deepEqual(brokenTold, ['blind']);
299
+ const hung = harness(() => new Promise(() => undefined), { maxBlindTicks: 2, tableTimeoutMs: 20 });
300
+ const hungTold = [];
301
+ hung.governor.watch({ rootPid: 100, parentPid: ME }, { maxRssBytes: 100 * MIB }, (breach) => hungTold.push(breach.kind));
302
+ await within(hung.governor.tick(), 2_000, 'a tick over a table that never answers');
303
+ await within(hung.governor.tick(), 2_000, 'a second tick over a table that never answers');
304
+ assert.deepEqual(hungTold, ['blind'], 'a table that never settles is raced against a timer, so ticks go on and the count rises');
305
+ await within(assert.rejects(hung.governor.probe(), ProcessTableUnavailableError, 'and the probe is raced the same way: a table that never answers is not "can see"'), 2_000, 'the probe of a table that never answers');
306
+ assert.throws(() => new ProcessTreeGovernor({ maxBlindTicks: 0 }), RangeError);
307
+ assert.throws(() => new ProcessTreeGovernor({ tableTimeoutMs: 0 }), RangeError);
308
+ });
309
+ // ---- P: ports with real processes -----------------------------------------------------------
310
+ console.log('P ports (real processes)');
311
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-compute-isolation-'));
312
+ const ports = [];
313
+ function portOf(options = {}) {
314
+ const port = new ManagedDshRunPort({ workerPath: FIXTURE, graceMs: 50, ...options });
315
+ ports.push(port);
316
+ return port;
317
+ }
318
+ function pidFileEnv(name, extra = {}) {
319
+ const file = join(scratch, `${name}.pid`);
320
+ return { env: { ...process.env, TENANT_FIXTURE_PID_FILE: file, ...extra }, read: () => Number(readFileSync(file, 'utf8')) };
321
+ }
322
+ async function readPid(read) {
323
+ await waitFor(() => { try {
324
+ return read() > 0;
325
+ }
326
+ catch {
327
+ return false;
328
+ } }, 5_000, 'the worker pid file');
329
+ return read();
330
+ }
331
+ const fast = new ProcessTreeGovernor({ intervalMs: 100 });
332
+ await check('P1 a worker that leaks off-heap memory is killed once its tree is over the limit, with a typed reason', async () => {
333
+ const worker = pidFileEnv('memory');
334
+ const port = portOf({ env: worker.env, limits: { maxRssBytes: 300 * MIB, governor: fast } });
335
+ const pid = await readPid(worker.read);
336
+ const started = Date.now();
337
+ const error = await within(rejection(port.run('memory', { sessionId: 'p1' })), 30_000, 'a run whose worker leaks past its limit');
338
+ assert.ok(error instanceof ManagedDshResourceLimitError, `expected a resource-limit error, got ${String(error)}`);
339
+ assert.equal(error.breach.kind, 'memory');
340
+ assert.equal(error.breach.limit, 300 * MIB);
341
+ assert.ok(error.breach.observed > 300 * MIB);
342
+ assert.ok(Date.now() - started < 15_000, 'the kill is prompt');
343
+ await waitFor(() => !alive(pid), 5_000, 'the worker to be gone');
344
+ const again = await rejection(port.run('ok', { sessionId: 'p1' }));
345
+ assert.ok(again instanceof ManagedDshResourceLimitError, 'the port stays failed with the same reason');
346
+ });
347
+ await check('P2 a runaway grandchild counts against the tree: the whole tree is killed, not just the worker', async () => {
348
+ const worker = pidFileEnv('grandchild');
349
+ const port = portOf({ env: worker.env, limits: { maxRssBytes: 300 * MIB, governor: fast } });
350
+ const pid = await readPid(worker.read);
351
+ const spawned = await port.run('memory-in-child', { sessionId: 'p2' });
352
+ const childPid = spawned.events[0].childPid;
353
+ assert.ok(alive(childPid));
354
+ await waitFor(() => !alive(childPid) && !alive(pid), 15_000, 'the grandchild and the worker to be gone');
355
+ const error = await rejection(port.run('ok', { sessionId: 'p2' }));
356
+ assert.ok(error instanceof ManagedDshResourceLimitError);
357
+ assert.equal(error.breach.kind, 'memory');
358
+ });
359
+ await check('P3 a worker that spins is killed once its tree is over the CPU limit', async () => {
360
+ const worker = pidFileEnv('cpu');
361
+ const port = portOf({ env: worker.env, limits: { maxCpuSeconds: 1, governor: fast } });
362
+ const pid = await readPid(worker.read);
363
+ const error = await within(rejection(port.run('cpu', { sessionId: 'p3' })), 30_000, 'a run whose worker spins past its limit');
364
+ assert.ok(error instanceof ManagedDshResourceLimitError, `expected a resource-limit error, got ${String(error)}`);
365
+ assert.equal(error.breach.kind, 'cpu');
366
+ await waitFor(() => !alive(pid), 5_000, 'the worker to be gone');
367
+ });
368
+ await check('P4 the V8 heap ceiling reaches the worker, and a worker that exhausts it is a typed resource-limit death', async () => {
369
+ const worker = pidFileEnv('heap');
370
+ const port = portOf({ env: worker.env, limits: { heapMb: 64 } });
371
+ await readPid(worker.read);
372
+ const error = await rejection(port.run('heap', { sessionId: 'p4' }));
373
+ assert.ok(error instanceof ManagedDshResourceLimitError, `expected a resource-limit error, got ${String(error)}`);
374
+ assert.equal(error.breach.kind, 'heap');
375
+ assert.equal(error.breach.limit, 64 * MIB);
376
+ const inspector = portOf({ limits: { heapMb: 96 } });
377
+ const seen = await inspector.run('ok', { sessionId: 'p4b' });
378
+ assert.match(String(seen.events[0].nodeOptions), /--max-old-space-size=96/);
379
+ assert.ok(seen.events[0].execArgv.includes('--max-old-space-size=96'), 'the worker\'s own command line carries it too');
380
+ const inherited = portOf({ env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=9999 --no-warnings' }, limits: { heapMb: 96 } });
381
+ const merged = String((await inherited.run('ok', { sessionId: 'p4c' })).events[0].nodeOptions);
382
+ assert.match(merged, /--max-old-space-size=96/);
383
+ assert.doesNotMatch(merged, /9999/, 'the limit replaces a ceiling that was already set instead of sitting beside it');
384
+ assert.match(merged, /--no-warnings/, 'other options are kept');
385
+ });
386
+ await check('P5 a line that never ends is cut off at the cap: typed, and this process does not grow with it', async () => {
387
+ const worker = pidFileEnv('flood');
388
+ const port = portOf({ env: worker.env, limits: { maxLineChars: MIB } });
389
+ const pid = await readPid(worker.read);
390
+ const before = process.memoryUsage().rss;
391
+ const error = await within(rejection(port.run('flood', { sessionId: 'p5' })), 30_000, 'a run whose worker writes a line that never ends');
392
+ assert.ok(error instanceof ManagedDshResourceLimitError, `expected a resource-limit error, got ${String(error)}`);
393
+ assert.equal(error.breach.kind, 'output');
394
+ assert.equal(error.breach.limit, MIB);
395
+ assert.ok(error.breach.observed > MIB);
396
+ await waitFor(() => !alive(pid), 5_000, 'the worker to be gone');
397
+ const grown = process.memoryUsage().rss - before;
398
+ assert.ok(grown < 128 * MIB, `the parent grew by ${Math.round(grown / MIB)} MiB while a 256 MiB line was written`);
399
+ });
400
+ await check('P6 a runaway does not move its neighbours: another port answers promptly while it leaks, and after it is killed', async () => {
401
+ const hot = pidFileEnv('hot');
402
+ const hotPort = portOf({ env: hot.env, limits: { maxRssBytes: 300 * MIB, governor: fast } });
403
+ const neighbour = portOf({ limits: { maxRssBytes: 300 * MIB, governor: fast } });
404
+ await readPid(hot.read);
405
+ const runaway = rejection(hotPort.run('memory', { sessionId: 'p6-hot' }));
406
+ const latencies = [];
407
+ for (let attempt = 0; attempt < 3; attempt += 1) {
408
+ const started = Date.now();
409
+ const result = await neighbour.run('ok', { sessionId: `p6-${attempt}` });
410
+ latencies.push(Date.now() - started);
411
+ assert.equal(result.finalResponse, 'ok');
412
+ }
413
+ assert.ok(Math.max(...latencies) < 10_000, `the neighbour answered in ${latencies.join(', ')} ms while the other leaked`);
414
+ assert.ok(await within(runaway, 30_000, 'the runaway worker\'s run') instanceof ManagedDshResourceLimitError);
415
+ assert.equal((await neighbour.run('ok', { sessionId: 'p6-after' })).finalResponse, 'ok', 'the neighbour is still served after the kill');
416
+ });
417
+ await check('P7 without limits nothing changes: no ceiling in the environment, nothing watched, the shared governor idle', async () => {
418
+ const before = sharedProcessTreeGovernor().size;
419
+ const port = portOf();
420
+ const result = await port.run('ok', { sessionId: 'p7' });
421
+ assert.doesNotMatch(String(result.events[0].nodeOptions ?? ''), /max-old-space-size/);
422
+ assert.equal(sharedProcessTreeGovernor().size, before);
423
+ const heapOnly = portOf({ limits: { heapMb: 128 } });
424
+ await heapOnly.run('ok', { sessionId: 'p7b' });
425
+ assert.equal(sharedProcessTreeGovernor().size, before, 'a heap ceiling alone needs no sampler');
426
+ });
427
+ await check('P8 closing a port stops its watch; a port whose limits are not positive numbers is refused before it spawns anything', async () => {
428
+ const governor = new ProcessTreeGovernor({ intervalMs: 100 });
429
+ const port = portOf({ limits: { maxRssBytes: 1_000 * MIB, governor } });
430
+ await port.run('ok', { sessionId: 'p8' });
431
+ assert.equal(governor.size, 1);
432
+ const closing = port.close();
433
+ assert.equal(governor.size, 0, 'the watch ends the moment close begins, not when the worker finally exits');
434
+ await closing;
435
+ for (const limits of [{ heapMb: 0 }, { maxRssBytes: -1 }, { maxCpuSeconds: Number.NaN }, { maxLineChars: 0 }]) {
436
+ assert.throws(() => new ManagedDshRunPort({ workerPath: FIXTURE, limits }), RangeError);
437
+ }
438
+ governor.close();
439
+ });
440
+ await check('P10 a governor that was asked for and never attached says so; one that attached is silent', async () => {
441
+ const silentLines = [];
442
+ const silent = portOf({
443
+ env: { ...process.env, TENANT_FIXTURE_NO_LIFECYCLE: '1' },
444
+ limits: { maxRssBytes: 1_000 * MIB, attachDeadlineMs: 150, governor: fast },
445
+ bootLog: (line) => silentLines.push(line),
446
+ });
447
+ await waitFor(() => silentLines.length > 0, 5_000, 'the missing governor to be reported');
448
+ assert.match(silentLines[0], /^RESOURCE_GOVERNOR_UNATTACHED \{"role":"unspecified","afterMs":150\}$/);
449
+ void silent;
450
+ const attachedLines = [];
451
+ const attached = portOf({ limits: { maxRssBytes: 1_000 * MIB, attachDeadlineMs: 150, governor: fast }, bootLog: (line) => attachedLines.push(line) });
452
+ await attached.run('ok', { sessionId: 'p10' });
453
+ await sleep(400);
454
+ assert.deepEqual(attachedLines, []);
455
+ const heapOnly = [];
456
+ portOf({ env: { ...process.env, TENANT_FIXTURE_NO_LIFECYCLE: '1' }, limits: { heapMb: 64, attachDeadlineMs: 150 }, bootLog: (line) => heapOnly.push(line) });
457
+ await sleep(400);
458
+ assert.deepEqual(heapOnly, [], 'a heap ceiling needs no sampler, so nothing is missing');
459
+ assert.throws(() => new ManagedDshRunPort({ workerPath: FIXTURE, limits: { attachDeadlineMs: 0 } }), RangeError);
460
+ });
461
+ await check('P11 a worker that reports a process that is not its own is not left ungoverned: the port fails with a typed reason and the worker is torn down', async () => {
462
+ const worker = pidFileEnv('liar', { TENANT_FIXTURE_LIE_PID: '1' });
463
+ const watcher = new ProcessTreeGovernor({ intervalMs: 50, maxBlindTicks: 3 });
464
+ const port = portOf({ env: worker.env, limits: { maxRssBytes: 1_000 * MIB, governor: watcher } });
465
+ const pid = await readPid(worker.read);
466
+ await waitFor(() => !alive(pid), 10_000, 'the worker whose tree could not be seen to be torn down');
467
+ const error = await rejection(port.run('ok', { sessionId: 'p11' }));
468
+ assert.ok(error instanceof ManagedDshResourceLimitError, `expected a resource-limit error, got ${String(error)}`);
469
+ assert.equal(error.breach.kind, 'blind');
470
+ watcher.close();
471
+ });
472
+ await check('P9 the planner hands its limits to the real worker: the ceiling is on its command line and its tree is watched until the port closes', async () => {
473
+ const governor = new ProcessTreeGovernor({ intervalMs: 3_600_000 });
474
+ const stateRoot = mkdtempSync(join(scratch, 'real-port-'));
475
+ const previousWarmup = process.env.GOTRY_BOOKING_COPILOT_WARMUP;
476
+ process.env.GOTRY_BOOKING_COPILOT_WARMUP = '0';
477
+ try {
478
+ const port = await createRealRunPort({
479
+ stateRoot,
480
+ env: { PATH: process.env.PATH, DEEPSEEK_API_KEY: 'unused-key', DEEPSEEK_BASE_URL: 'http://127.0.0.1:9/v1', DEEPSEEK_PROVIDER: 'gateway-chat', DEEPSEEK_MODEL: 'probe-model' },
481
+ resourceLimits: { heapMb: 77, maxRssBytes: 4_000 * MIB, governor },
482
+ });
483
+ await port.warmup();
484
+ const table = execFileSync('/bin/ps', ['-A', '-o', 'pid=,ppid=,command='], { encoding: 'utf8', env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' } });
485
+ const workers = table.split('\n').filter((line) => new RegExp(`^\\s*\\d+\\s+${process.pid}\\s+`).test(line) && line.includes('--max-old-space-size=77'));
486
+ assert.equal(workers.length, 1, 'one real worker, started with the ceiling on its command line');
487
+ assert.equal(governor.size, 1, 'and its tree is watched');
488
+ await port.close();
489
+ assert.equal(governor.size, 0, 'closing the port ends the watch');
490
+ }
491
+ finally {
492
+ if (previousWarmup === undefined)
493
+ delete process.env.GOTRY_BOOKING_COPILOT_WARMUP;
494
+ else
495
+ process.env.GOTRY_BOOKING_COPILOT_WARMUP = previousWarmup;
496
+ governor.close();
497
+ }
498
+ });
499
+ // ---- D: planner ------------------------------------------------------------------------------
500
+ console.log('D planner');
501
+ const task = {
502
+ schemaVersion: 'booking.surface',
503
+ taskId: 'task-compute-1',
504
+ contextRef: 'ctx-compute-1',
505
+ surface: 'tenant',
506
+ revision: 0,
507
+ allowedActions: ['search.patch', 'search.run'],
508
+ userTurnCount: 1,
509
+ lastTurnId: 'turn-compute-1',
510
+ operationCount: 0,
511
+ phase: 'planning',
512
+ lastSequence: 0,
513
+ availability: { initialized: true, recoveryStarted: false, availabilityPhase: 'need_offers', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [] },
514
+ };
515
+ task.workspaceSnapshot = {
516
+ schemaVersion: 'booking.surface',
517
+ contextRef: task.contextRef,
518
+ surface: 'tenant',
519
+ revision: 0,
520
+ locale: 'zh-CN',
521
+ currency: 'AED',
522
+ searchDraft: {},
523
+ results: { status: 'idle' },
524
+ visibleHotels: [],
525
+ loadedOffers: [],
526
+ shortlistedOfferRefs: [],
527
+ capabilities: { surface: 'tenant', allowedActions: ['search.patch', 'search.run'] },
528
+ };
529
+ const turn = {
530
+ schemaVersion: 'booking.surface',
531
+ kind: 'user.turn',
532
+ taskId: task.taskId,
533
+ turnId: task.lastTurnId,
534
+ workspace: task.workspaceSnapshot,
535
+ request: { text: 'Find hotels' },
536
+ };
537
+ await check('D1 a resource kill ends the turn as a non-retryable PLANNER_FAILED, once, on the port that was killed', async () => {
538
+ let factoryCalls = 0;
539
+ let closed = 0;
540
+ let runs = 0;
541
+ const metrics = [];
542
+ const planner = await createDshEmbeddedBookingPlanner({
543
+ runPortFactory: () => {
544
+ factoryCalls += 1;
545
+ const port = {
546
+ run: async () => { runs += 1; throw new ManagedDshResourceLimitError({ kind: 'memory', limit: 1, observed: 2 }); },
547
+ async close() { closed += 1; },
548
+ bootObservation: () => undefined,
549
+ };
550
+ return port;
551
+ },
552
+ turnTimeoutMs: 20_000,
553
+ onMetric: (metric) => metrics.push(metric),
554
+ });
555
+ const decisions = await planner.plannerFactory(task).next({ task, turn });
556
+ assert.equal(decisions.length, 1);
557
+ const decision = decisions[0];
558
+ assert.equal(decision.kind, 'error');
559
+ if (decision.kind !== 'error')
560
+ throw new Error('unreachable');
561
+ assert.equal(decision.error.code, 'PLANNER_FAILED');
562
+ assert.equal(decision.error.retryable, false);
563
+ assert.equal(runs, 1, 'the killed run is not repeated');
564
+ assert.equal(factoryCalls, 1, 'no fresh port is made to run the same turn again');
565
+ assert.equal(closed, 1, 'the killed port is retired');
566
+ assert.equal(metrics[0]?.outcome, 'resource_limit');
567
+ assert.doesNotMatch(JSON.stringify(decision), /memory|limit|observed/i, 'the answer names no limit and no number');
568
+ await planner.close();
569
+ });
570
+ await check('D2 end to end: a real worker tree that leaks inside a real planner turn is killed, answered, and gone', async () => {
571
+ const worker = pidFileEnv('planner', { TENANT_FIXTURE_MODE: 'memory' });
572
+ let created;
573
+ const metrics = [];
574
+ const planner = await createDshEmbeddedBookingPlanner({
575
+ runPortFactory: () => {
576
+ created = createManagedDshRunPort({ workerPath: FIXTURE, graceMs: 50, env: worker.env, limits: { maxRssBytes: 300 * MIB, governor: fast } });
577
+ return created;
578
+ },
579
+ turnTimeoutMs: 60_000,
580
+ stallSoftBudgetMs: 60_000,
581
+ onMetric: (metric) => metrics.push(metric),
582
+ });
583
+ const started = Date.now();
584
+ const decisions = await planner.plannerFactory(task).next({ task, turn });
585
+ const decision = decisions[0];
586
+ assert.equal(decision.kind, 'error');
587
+ if (decision.kind !== 'error')
588
+ throw new Error('unreachable');
589
+ assert.equal(decision.error.code, 'PLANNER_FAILED');
590
+ assert.equal(decision.error.retryable, false);
591
+ assert.equal(metrics[0]?.outcome, 'resource_limit');
592
+ assert.ok(Date.now() - started < 30_000);
593
+ await waitFor(() => !alive(worker.read()), 5_000, 'the planner worker to be gone');
594
+ await planner.close();
595
+ });
596
+ await check('D4 limits that need a sampler are checked before the first child starts; a blind sampler stops the real tenant planner, loudly', async () => {
597
+ const blind = new ProcessTreeGovernor({ table: async () => [] });
598
+ const seeing = new ProcessTreeGovernor({ table: async () => [row(process.pid, 1, 50)] });
599
+ await assertPlannerLimitsEnforceable({ maxRssBytes: MIB, governor: seeing });
600
+ await assertPlannerLimitsEnforceable({ heapMb: 64, maxLineChars: MIB, governor: blind });
601
+ await assert.rejects(assertPlannerLimitsEnforceable({ maxCpuSeconds: 5, governor: blind }), ProcessTableUnavailableError);
602
+ const stubTickets = { issue: () => { throw new Error('no child may be started'); } };
603
+ const provider = createDshTenantPlannerProvider({ llmTickets: stubTickets, resourceLimits: { maxRssBytes: MIB, governor: blind } });
604
+ await assert.rejects(provider.forTenant({ tenantRoot: join(scratch, 'tenant-blind'), cachePartition: `pc-${'a'.repeat(32)}`, reportUsage() { } }), ProcessTableUnavailableError);
605
+ await provider.close();
606
+ });
607
+ await check('D5 every real port checks that the sampler can see before it starts a child, not only the first tenant: a blind sampler starts nothing', async () => {
608
+ const blind = new ProcessTreeGovernor({ table: async () => [] });
609
+ const children = () => execFileSync('/bin/ps', ['-A', '-o', 'pid=,ppid='], { encoding: 'utf8', env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' } })
610
+ .split('\n').filter((line) => new RegExp(`^\\s*\\d+\\s+${process.pid}\\s*$`).test(line)).length;
611
+ const before = children();
612
+ for (let attempt = 0; attempt < 2; attempt += 1) {
613
+ await assert.rejects(createRealRunPort({
614
+ stateRoot: mkdtempSync(join(scratch, 'blind-port-')),
615
+ env: { PATH: process.env.PATH, DEEPSEEK_API_KEY: 'unused-key', DEEPSEEK_BASE_URL: 'http://127.0.0.1:9/v1', DEEPSEEK_PROVIDER: 'gateway-chat', DEEPSEEK_MODEL: 'probe-model' },
616
+ resourceLimits: { maxRssBytes: 1_000 * MIB, governor: blind },
617
+ }), ProcessTableUnavailableError);
618
+ }
619
+ assert.equal(children(), before, 'no child was started');
620
+ });
621
+ await check('D3 the tenant planner provider hands every tenant the default ceilings, and a deployment can set its own', async () => {
622
+ const seen = [];
623
+ const fake = async (options) => {
624
+ seen.push(options);
625
+ return { plannerFactory: () => ({ next: async () => [] }), close: async () => undefined };
626
+ };
627
+ const context = { tenantRoot: join(scratch, 'tenant-a'), cachePartition: 'unused', reportUsage: () => undefined };
628
+ await createDshTenantPlannerProvider({}, fake).forTenant(context);
629
+ assert.deepEqual(seen[0].resourceLimits, DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS);
630
+ const custom = { heapMb: 256, maxRssBytes: 700 * MIB };
631
+ await createDshTenantPlannerProvider({ resourceLimits: custom }, fake).forTenant(context);
632
+ assert.deepEqual(seen[1].resourceLimits, custom);
633
+ assert.ok(Object.isFrozen(DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS));
634
+ const limits = DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS;
635
+ assert.ok(limits.heapMb > 0 && limits.maxRssBytes > limits.heapMb * MIB, 'the tree ceiling leaves room for more than one heap');
636
+ assert.ok(limits.maxCpuSeconds > 0 && limits.maxLineChars > 0);
637
+ });
638
+ // ---- R: replica capacity ---------------------------------------------------------------------------
639
+ console.log('R replica capacity');
640
+ const ORG = 'acme';
641
+ const ORG_KEY = Buffer.alloc(32, 0x42);
642
+ const tenantNumber = (n) => deriveLedgerTenantId(ORG_KEY, 'https://idp.acme.example', `capacity-user-${n}`);
643
+ const routerCleanups = [];
644
+ function capacityFleet(name, maxOpenTenants) {
645
+ const dir = join(scratch, name);
646
+ const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db') });
647
+ routerCleanups.push(() => leases.close());
648
+ const router = new TenantRouter({
649
+ stateRoot: join(dir, 'node'), replicaId: `replica-${name}`, leases, durable: new DirDurableStore(join(dir, 'durable')),
650
+ ttlMs: 60_000, marginMs: 2_000, autoRenew: false, ...(maxOpenTenants === undefined ? {} : { maxOpenTenants }),
651
+ });
652
+ routerCleanups.push(() => router.close().then(() => undefined));
653
+ return { router, leases };
654
+ }
655
+ await check('R1 a replica at its cap refuses a new tenant before its lease or any file exists, and keeps serving the ones it has', async () => {
656
+ const { router, leases } = capacityFleet('r1', 3);
657
+ const handles = [await router.openTenant(ORG, tenantNumber(1)), await router.openTenant(ORG, tenantNumber(2)), await router.openTenant(ORG, tenantNumber(3))];
658
+ const refused = await rejection(router.openTenant(ORG, tenantNumber(4)));
659
+ assert.ok(refused instanceof ReplicaAtCapacityError);
660
+ assert.equal(refused.limit, 3);
661
+ assert.equal(existsSync(router.tenantRootOf(ORG, tenantNumber(4))), false, 'no directory for the refused tenant');
662
+ assert.ok(!leases.history().some((entry) => entry.key.endsWith(tenantNumber(4))), 'no lease was taken for it');
663
+ assert.strictEqual(await router.openTenant(ORG, tenantNumber(2)), handles[1], 'a tenant already held is still served at the cap');
664
+ assert.equal(router.openCount(), 3);
665
+ await handles[0].handoff();
666
+ const fits = await router.openTenant(ORG, tenantNumber(4));
667
+ assert.ok(fits.isOpen(), 'a handoff frees a slot');
668
+ });
669
+ await check('R2 the cap counts tenants still opening, so a burst cannot overshoot it', async () => {
670
+ const { router } = capacityFleet('r2', 3);
671
+ const settled = await Promise.allSettled(Array.from({ length: 12 }, (_, i) => router.openTenant(ORG, tenantNumber(100 + i))));
672
+ const opened = settled.filter((result) => result.status === 'fulfilled').length;
673
+ const refused = settled.filter((result) => result.status === 'rejected' && result.reason instanceof ReplicaAtCapacityError).length;
674
+ assert.equal(opened, 3);
675
+ assert.equal(refused, 9);
676
+ assert.equal(router.openCount(), 3);
677
+ const same = await Promise.all(Array.from({ length: 6 }, () => router.openTenant(ORG, tenantNumber(100))));
678
+ assert.ok(same.every((handle) => handle === same[0]), 'concurrent opens of one tenant still share one acquisition');
679
+ });
680
+ await check('R3 without a cap nothing changes, and a cap that is not a positive integer is refused at construction', async () => {
681
+ const { router } = capacityFleet('r3');
682
+ for (let i = 0; i < 25; i += 1)
683
+ await router.openTenant(ORG, tenantNumber(200 + i));
684
+ assert.equal(router.openCount(), 25);
685
+ for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
686
+ assert.throws(() => new TenantRouter({ stateRoot: join(scratch, 'bad'), replicaId: 'x', leases: {}, durable: {}, maxOpenTenants: bad }), RangeError, String(bad));
687
+ }
688
+ });
689
+ await check('R4 at the HTTP boundary a full replica is a typed 503 with no retry hint, and names no limit', () => {
690
+ const rejection503 = tenantRejection(new ReplicaAtCapacityError(3));
691
+ assert.equal(rejection503.status, 503);
692
+ assert.equal(rejection503.retryAfterSeconds, undefined, 'no retry hint: only a handoff frees a slot, so retrying this replica soon would not help');
693
+ assert.deepEqual(rejection503.body, { error: { code: 'tenant_unavailable' } });
694
+ });
695
+ // ---- cleanup ---------------------------------------------------------------------------------
696
+ await Promise.allSettled(ports.map((port) => port.close()));
697
+ for (const cleanup of routerCleanups.reverse()) {
698
+ try {
699
+ await cleanup();
700
+ }
701
+ catch { /* best effort */ }
702
+ }
703
+ fast.close();
704
+ sharedProcessTreeGovernor().close();
705
+ rmSync(scratch, { recursive: true, force: true });
706
+ console.log(`tenant-compute-isolation: ${passed} checks passed`);
707
+ process.exit(0);