@nimbus-sh/fabric 0.5.0 → 0.7.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/dist/bindings.d.ts +5 -49
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +59 -27
- package/dist/fanout.d.ts +4 -11
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +38 -42
- package/dist/host-dispatch.d.ts +6 -0
- package/dist/host-dispatch.d.ts.map +1 -0
- package/dist/host-dispatch.js +27 -0
- package/dist/isolate-pool.d.ts +21 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +53 -9
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +60 -33
- package/dist/turn-budget.d.ts +5 -1
- package/dist/turn-budget.d.ts.map +1 -1
- package/dist/turn-budget.js +45 -12
- package/package.json +4 -4
- package/src/bindings.ts +57 -92
- package/src/fanout.ts +48 -67
- package/src/host-dispatch.ts +34 -0
- package/src/isolate-pool.ts +106 -14
- package/src/process-host.ts +64 -44
- package/src/turn-budget.ts +41 -12
package/src/process-host.ts
CHANGED
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
68
68
|
import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
69
69
|
import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
|
|
70
|
+
import { hostNamespaceBinding, hostOpDispatch, type HostNamespaceBinding } from './host-dispatch.js';
|
|
71
|
+
import { z } from 'zod/v4';
|
|
70
72
|
import {
|
|
71
73
|
type HostedProcess,
|
|
72
74
|
type OneShotParams,
|
|
@@ -219,20 +221,6 @@ export interface HostedHttpResponse {
|
|
|
219
221
|
body: ReadableStream | null;
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
/**
|
|
223
|
-
* Every method a hosting sibling must answer. Checked as a set at first
|
|
224
|
-
* contact: a stub that has the probe but not the router is a deployment skew,
|
|
225
|
-
* and finding that out mid-process is finding it out too late.
|
|
226
|
-
*/
|
|
227
|
-
const PROCESS_PEER_METHODS = [
|
|
228
|
-
'_rpcProcessHostProbe',
|
|
229
|
-
'_rpcHostProcess',
|
|
230
|
-
'_rpcAwaitHostedOpen',
|
|
231
|
-
'_rpcAwaitHostedBoot',
|
|
232
|
-
'_rpcRouteHostedHttp',
|
|
233
|
-
'_rpcCancelHostProcess',
|
|
234
|
-
] as const;
|
|
235
|
-
|
|
236
224
|
/** The host-process surface a sibling session DO exposes. */
|
|
237
225
|
interface ProcessPeerStub {
|
|
238
226
|
_rpcProcessHostProbe(): Promise<{ isolateToken: string }>;
|
|
@@ -257,41 +245,70 @@ interface ProcessPeerStub {
|
|
|
257
245
|
export const HOSTED_WEBSOCKET_KEY_HEADER = 'x-nimbus-hosted-websocket';
|
|
258
246
|
export const HOSTED_WEBSOCKET_CAPABILITY_HEADER = 'x-nimbus-hosted-websocket-capability';
|
|
259
247
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Peer stubs forward one supervisorOp entrypoint: every method the host-
|
|
250
|
+
* process surface needs is an envelope op, not a private _rpc* method.
|
|
251
|
+
*/
|
|
252
|
+
function peerNamespace(env: object): HostNamespaceBinding {
|
|
253
|
+
try {
|
|
254
|
+
return hostNamespaceBinding(env, 'ProcessFabric');
|
|
255
|
+
} catch (e) {
|
|
256
|
+
if (e instanceof BindingError) {
|
|
257
|
+
throw new BindingError(
|
|
258
|
+
e.message
|
|
259
|
+
+ " NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
|
|
260
|
+
+ "Durable Object; name the host's own binding with composeFabric({ hostNamespace }).",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
267
|
+
const PeerProbeResult = z.object({ isolateToken: z.string() });
|
|
268
|
+
const PeerOpenResult = z.object({ ok: z.boolean() });
|
|
269
|
+
const PeerBootResult = z.object({ payload: z.unknown() });
|
|
270
|
+
const PeerCancelResult = z.object({ cancelled: z.boolean() });
|
|
271
|
+
const PeerHttpResult = z.object({
|
|
272
|
+
status: z.number().int(),
|
|
273
|
+
statusText: z.string(),
|
|
274
|
+
headers: z.array(z.tuple([z.string(), z.string()])),
|
|
275
|
+
body: z.instanceof(ReadableStream).nullable(),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
function processPeerStub(value: object, peerName: string): ProcessPeerStub {
|
|
279
|
+
const dispatch = hostOpDispatch(value, `ProcessFabric peer '${peerName}'`);
|
|
280
|
+
const fetchFn = (typeof value === 'object' || typeof value === 'function') && value !== null
|
|
281
|
+
? Reflect.get(value, 'fetch')
|
|
268
282
|
: undefined;
|
|
269
|
-
if (
|
|
270
|
-
|| typeof Reflect.get(ns, 'idFromName') !== 'function'
|
|
271
|
-
|| typeof Reflect.get(ns, 'get') !== 'function') {
|
|
283
|
+
if (typeof fetchFn !== 'function') {
|
|
272
284
|
throw new BindingError(
|
|
273
|
-
|
|
274
|
-
+
|
|
275
|
-
+ 'Durable Object; add the binding via durable_objects.bindings in wrangler.jsonc.',
|
|
285
|
+
`ProcessFabric: peer '${peerName}' exposes no fetch() — the hosted-websocket `
|
|
286
|
+
+ 'upgrade leg still travels as a service-binding fetch.',
|
|
276
287
|
);
|
|
277
288
|
}
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
289
|
+
const adapter: ProcessPeerStub = {
|
|
290
|
+
_rpcProcessHostProbe: async () => PeerProbeResult.parse(await dispatch({ op: 'processHostProbe', args: [] })),
|
|
291
|
+
_rpcHostProcess: async (boot, opts) => PeerOpenResult.parse(await dispatch({ op: 'hostProcess', args: [boot, opts] })),
|
|
292
|
+
_rpcAwaitHostedOpen: async (key) => PeerOpenResult.parse(await dispatch({ op: 'awaitHostedOpen', args: [key] })),
|
|
293
|
+
_rpcAwaitHostedBoot: async (key) => {
|
|
294
|
+
const result = PeerBootResult.parse(await dispatch({ op: 'awaitHostedBoot', args: [key] }));
|
|
295
|
+
return { payload: result.payload };
|
|
296
|
+
},
|
|
297
|
+
_rpcRouteHostedHttp: async (key, request) => PeerHttpResult.parse(await dispatch({ op: 'routeHostedHttp', args: [key, request] })),
|
|
298
|
+
_rpcCancelHostProcess: async (key) => PeerCancelResult.parse(await dispatch({ op: 'cancelHostProcess', args: [key] })),
|
|
299
|
+
fetch: async (request) => {
|
|
300
|
+
const response = await Reflect.apply(fetchFn, value, [request]);
|
|
301
|
+
if (!(response instanceof Response)) throw new BindingError(`ProcessFabric: peer '${peerName}' returned no Response`);
|
|
302
|
+
return response;
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
// The adapter wraps the raw stub; releasing the adapter must release it —
|
|
306
|
+
// a held RPC stub pins the peer DO for the session's life.
|
|
307
|
+
const disposerKey = Reflect.get(Symbol, 'dispose');
|
|
308
|
+
if (typeof disposerKey === 'symbol') {
|
|
309
|
+
Object.defineProperty(adapter, disposerKey, { value: () => disposeRpcResource(value) });
|
|
293
310
|
}
|
|
294
|
-
return
|
|
311
|
+
return adapter;
|
|
295
312
|
}
|
|
296
313
|
|
|
297
314
|
interface PeerPlacement {
|
|
@@ -312,13 +329,16 @@ class PeerProcessHost implements ProcessHost {
|
|
|
312
329
|
storageSharedWithSession: false,
|
|
313
330
|
};
|
|
314
331
|
|
|
315
|
-
private readonly ns:
|
|
332
|
+
private readonly ns: HostNamespaceBinding;
|
|
316
333
|
private readonly env: ResidentFacetEnv;
|
|
317
334
|
private readonly coordDoId: string;
|
|
318
335
|
/** pid → the isolate token of the peer currently hosting that process. */
|
|
319
336
|
private readonly tokensInUse = new Map<number, string>();
|
|
320
337
|
|
|
321
338
|
constructor(private readonly ctx: DurableObjectState, env: unknown) {
|
|
339
|
+
if (env === null || (typeof env !== 'object' && typeof env !== 'function')) {
|
|
340
|
+
throw new BindingError('ProcessFabric: a peer host requires environment bindings');
|
|
341
|
+
}
|
|
322
342
|
this.ns = peerNamespace(env);
|
|
323
343
|
this.env = (env ?? {}) as ResidentFacetEnv;
|
|
324
344
|
this.coordDoId = ctx.id.toString();
|
|
@@ -445,7 +465,7 @@ class PeerProcessHost implements ProcessHost {
|
|
|
445
465
|
/** One sibling name, resolved and probed. Leaks nothing on failure. */
|
|
446
466
|
private async _probePlacement(pid: number, attempt: number): Promise<PeerPlacement> {
|
|
447
467
|
const peerName = `${this.coordDoId}:proc:${pid}:${attempt}`;
|
|
448
|
-
let resource:
|
|
468
|
+
let resource: DurableObjectStub | null = null;
|
|
449
469
|
try {
|
|
450
470
|
resource = this.ns.get(this.ns.idFromName(peerName));
|
|
451
471
|
const stub = processPeerStub(resource, peerName);
|
package/src/turn-budget.ts
CHANGED
|
@@ -142,7 +142,7 @@ export interface PacedWorkHost {
|
|
|
142
142
|
* reason. Without it the pump degrades to a same-context timer — see
|
|
143
143
|
* {@link PacedWork.nextTurn}.
|
|
144
144
|
*/
|
|
145
|
-
requestTurn?: (notBefore?: number) => void
|
|
145
|
+
requestTurn?: (notBefore?: number) => void | Promise<void>;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
@@ -160,7 +160,8 @@ export class PacedWork implements TurnScheduler {
|
|
|
160
160
|
* half-built work for pids that no longer exist, where re-driving a launch
|
|
161
161
|
* from its inputs is the same idempotent work again.
|
|
162
162
|
*/
|
|
163
|
-
private waiters: Array<{ resume: () => void; chunkEnded: Promise<void>; notBefore: number }> = [];
|
|
163
|
+
private waiters: Array<{ resume: () => void; reject: (error: unknown) => void; chunkEnded: Promise<void>; notBefore: number }> = [];
|
|
164
|
+
private closed: Error | undefined;
|
|
164
165
|
|
|
165
166
|
/**
|
|
166
167
|
* `ctx` keys the cold-start queue the pump drains first on every turn it
|
|
@@ -182,10 +183,17 @@ export class PacedWork implements TurnScheduler {
|
|
|
182
183
|
* responsiveness but keeps the behaviour.
|
|
183
184
|
*/
|
|
184
185
|
nextTurn(chunkEnded: Promise<void>, notBefore = 0): Promise<void> {
|
|
185
|
-
return
|
|
186
|
-
|
|
186
|
+
if (this.closed) return Promise.reject(this.closed);
|
|
187
|
+
return new Promise<void>((resume, reject) => {
|
|
188
|
+
const waiter = { resume, reject, chunkEnded, notBefore };
|
|
189
|
+
this.waiters.push(waiter);
|
|
187
190
|
if (this.host.requestTurn) {
|
|
188
|
-
this.
|
|
191
|
+
void this.requestTurn(notBefore).catch((error: unknown) => {
|
|
192
|
+
const index = this.waiters.indexOf(waiter);
|
|
193
|
+
if (index < 0) return;
|
|
194
|
+
this.waiters.splice(index, 1);
|
|
195
|
+
waiter.reject(error);
|
|
196
|
+
});
|
|
189
197
|
return;
|
|
190
198
|
}
|
|
191
199
|
setTimeout(() => { void this.pump(); }, Math.max(0, notBefore - Date.now()));
|
|
@@ -201,18 +209,39 @@ export class PacedWork implements TurnScheduler {
|
|
|
201
209
|
* the runtime may tear the context down mid-chunk.
|
|
202
210
|
*/
|
|
203
211
|
async pump(): Promise<void> {
|
|
204
|
-
|
|
205
|
-
// the resident-launch journal's recovery sits (`onColdStart`): the pump
|
|
206
|
-
// is what an alarm calls, and the first turn after a reset is the
|
|
207
|
-
// re-delivered alarm of a launch the reset interrupted.
|
|
212
|
+
if (this.closed) return;
|
|
208
213
|
await runColdStart(this.ctx);
|
|
214
|
+
if (this.closed) return;
|
|
209
215
|
const now = Date.now();
|
|
210
216
|
const waiting = this.waiters.filter((waiter) => waiter.notBefore <= now);
|
|
211
217
|
this.waiters = this.waiters.filter((waiter) => waiter.notBefore > now);
|
|
212
|
-
|
|
213
|
-
|
|
218
|
+
const future = [...this.waiters];
|
|
219
|
+
const scheduled = future.length > 0
|
|
220
|
+
? this.requestTurn(Math.min(...future.map((waiter) => waiter.notBefore))).catch((error: unknown) => {
|
|
221
|
+
for (const waiter of future) {
|
|
222
|
+
const index = this.waiters.indexOf(waiter);
|
|
223
|
+
if (index < 0) continue;
|
|
224
|
+
this.waiters.splice(index, 1);
|
|
225
|
+
waiter.reject(error);
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
})
|
|
229
|
+
: Promise.resolve();
|
|
214
230
|
for (const waiter of waiting) waiter.resume();
|
|
215
|
-
await Promise.
|
|
231
|
+
const results = await Promise.allSettled([scheduled, ...waiting.map((waiter) => waiter.chunkEnded)]);
|
|
232
|
+
const failure = results.find((result) => result.status === 'rejected');
|
|
233
|
+
if (failure?.status === 'rejected') throw failure.reason;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async requestTurn(notBefore: number): Promise<void> {
|
|
237
|
+
if (this.closed) throw this.closed;
|
|
238
|
+
await this.host.requestTurn?.(notBefore);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Reject parked work before the owner drains launch cleanup and cancels its alarm. */
|
|
242
|
+
close(reason = new Error('Launch scheduler is closed')): void {
|
|
243
|
+
this.closed ??= reason;
|
|
244
|
+
for (const waiter of this.waiters.splice(0)) waiter.reject(this.closed);
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
/** Whether any launch is suspended waiting for a turn. */
|