@nimbus-sh/platform 0.7.1 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-sh/platform",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "The measured Cloudflare platform truths Nimbus is built on \u2014 error taxonomy, limits tables, budget coordination, and wire-format primitives, with zero runtime dependencies.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -82,12 +82,21 @@ export class SupervisorAllocationBudget {
82
82
  return this._acquire(bytes, signal, false);
83
83
  }
84
84
 
85
+ /** Reserve bytes now or not at all, driving the lifecycle as acquire does. */
86
+ tryAcquire(bytes: number): ResizableCreditLease | null {
87
+ const credit = this.credits.tryAcquire(bytes);
88
+ return credit ? this.wrap(credit, true) : null;
89
+ }
90
+
85
91
  private async _acquire(
86
92
  bytes: number,
87
93
  signal: AbortSignal | undefined,
88
94
  drivesLifecycle: boolean,
89
95
  ): Promise<ResizableCreditLease> {
90
- const credit = await this.credits.acquire(bytes, signal);
96
+ return this.wrap(await this.credits.acquire(bytes, signal), drivesLifecycle);
97
+ }
98
+
99
+ private wrap(credit: ResizableCreditLease, drivesLifecycle: boolean): ResizableCreditLease {
91
100
  if (drivesLifecycle) {
92
101
  this.lifecycleHolders++;
93
102
  if (!this.active) {
@@ -212,6 +221,11 @@ export function acquireSupervisorAllocation(
212
221
  return supervisorAllocationBudget.acquire(bytes, signal);
213
222
  }
214
223
 
224
+ /** Reserve bytes now if they can be granted without waiting, else null. */
225
+ export function tryAcquireSupervisorAllocation(bytes: number): ResizableCreditLease | null {
226
+ return supervisorAllocationBudget.tryAcquire(bytes);
227
+ }
228
+
215
229
  /**
216
230
  * Reserve bytes for a filesystem READ. Takes the same byte credit as any
217
231
  * other owner but does not shrink the disposable VFS cache, which reads are
package/src/limits.ts CHANGED
@@ -66,13 +66,6 @@ export const HYDRATION_DEADLINE_MS = 30_000;
66
66
  */
67
67
  export const SQLITE_MAX_STATEMENT_BYTES = 100 * 1024;
68
68
 
69
- /**
70
- * Maximum bound parameters per query. The easiest cap to hit accidentally:
71
- * a batched insert of more than 100/columns rows in one statement breaches
72
- * it.
73
- */
74
- export const SQLITE_MAX_BOUND_PARAMETERS = 100;
75
-
76
69
  /**
77
70
  * Maximum bytes of one string, BLOB, or table ROW — the bound is per ROW,
78
71
  * key length included, not per value. The measured single-value ceiling is
@@ -108,14 +101,14 @@ export const BLOCK_CONCURRENCY_CANCEL_MS = 30_000;
108
101
  export const RESIDENT_KEEPALIVE_MS = 5_000;
109
102
 
110
103
  /**
111
- * How long the keep-alive holds a session after its last client is gone.
104
+ * How long the keep-alive holds a resident after its last client traffic.
112
105
  *
113
106
  * The keep-alive exists so a quiet process survives while its user is
114
107
  * present. Unbounded, it made every abandoned session with a dev server
115
108
  * run forever: the probe suite's cancelled runs left staging with resident
116
109
  * watchers polling `stat` at ~7.5/s in the isolate every session shares,
117
110
  * and launches that pass on a fresh Worker reset there (2026-09-22). Past
118
- * this grace with no attached socket and no request, the session idles
111
+ * this grace without runtime client traffic, even with idle sockets, the session idles
119
112
  * out as it did before the keep-alive, and the launch journal re-drives
120
113
  * the process when a client returns. Long enough for a tab reload.
121
114
  */
@@ -196,6 +189,14 @@ export const SUPERVISOR_HEAP_CEILING_BYTES = 64 * 1024 * 1024;
196
189
  // of a facet's measured ~208-256 MiB, the rest left to the program.
197
190
  export const FACET_OWN_WRITE_MEMORY_BYTES = 32 * 1024 * 1024;
198
191
 
192
+ // A WASI process's copy of the namespace and of the files it reads
193
+ // (core runtime/wasi/resident-filesystem.ts): held in its facet's heap beside
194
+ // the guest's linear memory, and never past this. Names are listed and bytes
195
+ // fetched only as the program asks for them, so the cap bounds a tree walker
196
+ // or a program that reads much, not what a short run holds. Past it the store
197
+ // holds nothing more and the session answers.
198
+ export const WASI_RESIDENT_STORE_BYTES = 32 * 1024 * 1024;
199
+
199
200
  // Shared allowance for transient allocations in the supervisor DO. With the
200
201
  // VFS LRU shrunk to 8 MiB during an active reservation, 40 MiB of admitted
201
202
  // payload plus the 9 MiB bundle baseline stays below the 64 MiB soft ceiling
@@ -0,0 +1,80 @@
1
+ /**
2
+ * lost-call.ts — the one policy for a call that never reaches the session.
3
+ *
4
+ * The platform drops calls into a Durable Object now and then, and the
5
+ * caller cannot tell a call that never arrived from one that is slow:
6
+ * - reads, SupervisorRPC → session, under three concurrent sessions
7
+ * (2026-09-28): 11 of 521 read batches never reached the session and
8
+ * were still pending 110-560 s later; every read that answered did so
9
+ * within 5 s;
10
+ * - write waves, a facet's writeBatchStream (2026-10-06, two next.js
11
+ * clones): the transport read the stream's first ~1 MiB within 0.1 s
12
+ * and nothing more, while the session held no stream, credit,
13
+ * allocation or transaction for it and kept committing other waves;
14
+ * healthy, with 8 producers saturating one session, the longest gap
15
+ * between a wave's reads was 2.5 s and the slowest wave 5.3 s;
16
+ * - calls that fail at once, "Network connection lost." (`retryable`),
17
+ * which classifyDoCall names.
18
+ *
19
+ * Detection and timings live here; the recovery depends on what a second
20
+ * delivery would do. A read is idempotent, so it is hedged: the same call
21
+ * on a fresh stub, the first answer taken (fabric do-calls `hedgeAfterMs`).
22
+ * A write wave carries a stream the first attempt consumes, so it cannot be
23
+ * hedged: it is re-encoded and re-sent under a fence (`WaveFence`), and the
24
+ * session refuses an attempt older than one it has seen from the same
25
+ * writer, so a late original never applies over its re-send.
26
+ */
27
+
28
+ import { classifyDoCall, isRetryableDoCall } from './oom-classify.js';
29
+
30
+ /** An idempotent call unanswered this long is hedged on a fresh stub. */
31
+ export const LOST_CALL_HEDGE_AFTER_MS = 5_000;
32
+
33
+ /** A stream nothing has read for this long, before its end, never reached the session. */
34
+ export const LOST_STREAM_STALL_MS = 10_000;
35
+
36
+ /** A call unanswered this long after its stream ended is taken as lost. */
37
+ export const LOST_STREAM_ANSWER_MS = 20_000;
38
+
39
+ /**
40
+ * How long a write-wave epoch the session issued admits attempts. Anything
41
+ * sent under an epoch the session does not know, or one past this, is
42
+ * refused: a call lost for longer than this can only be refused, never
43
+ * admitted. A writer opens a new epoch once half of this has passed.
44
+ */
45
+ export const WAVE_EPOCH_TTL_MS = 10 * 60_000;
46
+
47
+ /**
48
+ * Waits before each re-send of a lost non-idempotent call (±25% jitter):
49
+ * ~42 s in all, to outlast a coordinator queue deep enough to shed. Its
50
+ * length is the bound on re-sends.
51
+ */
52
+ export const LOST_CALL_RESEND_BACKOFF_MS: readonly number[] = [250, 1_000, 3_000, 6_000, 12_000, 20_000];
53
+
54
+ /**
55
+ * Whether `error` is the platform losing a fenced call: a dropped
56
+ * connection, a replaced isolate, a storage reset, or an object that shed
57
+ * it. Overloaded is included for fenced re-sends only: the platform's advice
58
+ * is not to retry an overloaded object, but these are few and backed off,
59
+ * and npm measured one re-send recover a 119-package install that one shed
60
+ * cost 31 packages (do-calls' unfenced verbs still never retry it).
61
+ */
62
+ export function isLostFencedCall(error: unknown): boolean {
63
+ const kind = classifyDoCall(error);
64
+ return isRetryableDoCall(kind) || kind === 'overloaded';
65
+ }
66
+
67
+ /** The attributes a lost call is logged and traced under, by every caller. */
68
+ export function lostCallAttributes(lost: {
69
+ operation: string;
70
+ attempt: number;
71
+ of: number;
72
+ reason: string;
73
+ }): Record<string, string | number> {
74
+ return {
75
+ 'do_call.operation': lost.operation,
76
+ 'do_call.attempts': lost.attempt,
77
+ 'do_call.max_attempts': lost.of,
78
+ 'do_call.lost_reason': lost.reason,
79
+ };
80
+ }
@@ -0,0 +1,57 @@
1
+ /** A deployed source asset: its digest, not its pathname, is its identity. */
2
+ export interface ModuleSourceAsset {
3
+ path: string;
4
+ sha256: string;
5
+ }
6
+
7
+ export interface ModuleSourceEnv {
8
+ ASSETS?: { fetch(request: Request): Promise<Response> };
9
+ }
10
+
11
+ /** Source already verified against its deployment pin. Shared across generated programs. */
12
+ export class ImmutableModuleSource {
13
+ readonly byteLength: number;
14
+ readonly text: string;
15
+ constructor(bytes: ArrayBuffer | Uint8Array, readonly asset: ModuleSourceAsset) {
16
+ this.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
17
+ this.byteLength = bytes.byteLength;
18
+ }
19
+ }
20
+
21
+ export type ModuleSourcePart = string | ImmutableModuleSource;
22
+ export type ModuleSourceRecipe = (string | ModuleSourceAsset)[];
23
+
24
+ /** A generated module retains its immutable pieces until the Loader needs its complete text. */
25
+ export class ModuleSource {
26
+ private joined: string | undefined;
27
+ readonly byteLength: number;
28
+ constructor(readonly parts: readonly ModuleSourcePart[]) {
29
+ this.byteLength = parts.reduce((bytes, part) => bytes + (typeof part === 'string' ? new TextEncoder().encode(part).byteLength : part.byteLength), 0);
30
+ }
31
+ get text(): string {
32
+ return this.joined ??= this.parts.map((part) => typeof part === 'string' ? part : part.text).join('');
33
+ }
34
+ recipe(): ModuleSourceRecipe {
35
+ return this.parts.map((part) => typeof part === 'string' ? part : part.asset);
36
+ }
37
+ }
38
+
39
+ /** Template interpolation without flattening immutable runtime libraries into process-owned text. */
40
+ export function moduleSource(strings: TemplateStringsArray, ...values: (string | number | boolean | null | undefined | ImmutableModuleSource | ModuleSource)[]): ModuleSource {
41
+ const parts: ModuleSourcePart[] = [];
42
+ const append = (part: ModuleSourcePart) => {
43
+ if (part === '') return;
44
+ const last = parts.length - 1;
45
+ const previous = parts[last];
46
+ if (typeof part === 'string' && typeof previous === 'string') parts[last] = previous + part;
47
+ else parts.push(part);
48
+ };
49
+ for (const [i, literal] of strings.entries()) {
50
+ append(literal);
51
+ if (i === values.length) continue;
52
+ const value = values[i];
53
+ if (value instanceof ModuleSource) value.parts.forEach(append);
54
+ else append(value instanceof ImmutableModuleSource ? value : String(value));
55
+ }
56
+ return new ModuleSource(parts);
57
+ }
@@ -209,6 +209,38 @@ export function isTransientDoReset(input: unknown): boolean {
209
209
  return false;
210
210
  }
211
211
 
212
+ /**
213
+ * The platform's own word that the actor a call reached was reset or killed:
214
+ * its `durableObjectReset` flag, or one of its termination sentences, whole.
215
+ * Text a program wrote never counts, however it reads: a process whose host
216
+ * this matches is ended (fabric process-host.ts HostLoss).
217
+ */
218
+ export function isHostReset(input: unknown): boolean {
219
+ if (typeof input === 'object' && input !== null && Reflect.get(input, 'durableObjectReset') === true) return true;
220
+ const message = readMessage(input).trim().replace(/^broken\.\w+;\s*jsg\.\w+:\s*/, '');
221
+ return HOST_TERMINATIONS.some((signature) => signature.test(message));
222
+ }
223
+
224
+ const HOST_TERMINATIONS: readonly RegExp[] = [
225
+ /^Durable Object's isolate exceeded its memory limit (?:and was reset\.|due to )/,
226
+ /^Worker (?:has )?exceeded memory limit\.$/,
227
+ /^(?:Python )?Worker exceeded CPU time limit\.?$/,
228
+ /^Durable Object exceeded its CPU time limit and was reset\.$/,
229
+ /^Durable Object reset because its code was updated\.$/,
230
+ /^Internal error (?:in|while starting up) Durable Object storage caused object to be reset; reference = \S+$/,
231
+ /^Durable Object storage operation exceeded timeout which caused the object to be reset\.$/,
232
+ ];
233
+
234
+ /**
235
+ * A failure the platform reports without a cause: workerd's "internal error;
236
+ * reference = <id>", whose reference only Cloudflare can look up. The error
237
+ * may carry `durableObjectReset` (the object it reached was reset), and
238
+ * nothing else; the caller has to name what it was doing.
239
+ */
240
+ export function isUnexplainedPlatformError(input: unknown): boolean {
241
+ return /^internal error; reference = \S+$/i.test(readMessage(input).trim());
242
+ }
243
+
212
244
  /**
213
245
  * Workerd shed the call because the target Durable Object's input-gate
214
246
  * queue was too deep or too old: "Durable Object is overloaded."
package/src/retry.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * retry.ts — the one retry policy: the loop that tries an operation again
3
+ * when its outcome is worth another try, and the wait between tries.
4
+ *
5
+ * Each caller names what is worth another try and how far apart, because
6
+ * that is what differs and was measured per transport:
7
+ * - the npm registry (npm/registry-retry.ts): a 5xx or a request that
8
+ * never answered, 500/1500/4500 ms;
9
+ * - a git server (git/pack/transport.ts): an idempotent request that
10
+ * failed in transit, stalled, or met a transient edge status, 1/3 s;
11
+ * - a lost write wave (lost-call.ts LOST_CALL_RESEND_BACKOFF_MS).
12
+ * Every wait is its schedule's entry ±25%, uniformly, so concurrent callers
13
+ * that failed together do not try again together.
14
+ *
15
+ * A facet carries these same functions and their dependencies in its
16
+ * build-time compiled task expression (the npm install facet does).
17
+ */
18
+
19
+ /**
20
+ * The wait before re-try `attempt` (0-based, the try that just failed):
21
+ * `schedule[attempt]`, its last entry once past the end, ±25%.
22
+ */
23
+ export function retryDelayMs(schedule: readonly number[], attempt: number): number {
24
+ const base = schedule[Math.min(attempt, schedule.length - 1)] ?? 0;
25
+ return Math.max(0, Math.round(base + (Math.random() * 2 - 1) * base * 0.25));
26
+ }
27
+
28
+ /** An attempt's outcome: what it returned, or what it threw. */
29
+ export type RetryOutcome<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown };
30
+
31
+ export interface RetryPolicy<T> {
32
+ /** How many times to try again after the first try. */
33
+ readonly retries: number;
34
+ /** The waits before each re-try, for retryDelayMs. */
35
+ readonly schedule: readonly number[];
36
+ /** Why `outcome` is worth another try, or null to keep it. Not asked of the last try. */
37
+ retryReason(outcome: RetryOutcome<T>): string | null;
38
+ /** A returned value given up for another try (its body cancelled, its stub disposed). */
39
+ discard?(value: T): unknown;
40
+ /** Each re-try, 1-based, with its wait and why. */
41
+ onRetry?(retry: number, of: number, delayMs: number, reason: string): void;
42
+ }
43
+
44
+ /**
45
+ * `attempt(n)` for n = 0, 1, … until its outcome is not worth another try
46
+ * or the policy's re-tries are spent; then that outcome: its value
47
+ * returned, its error thrown.
48
+ */
49
+ export async function retrying<T>(attempt: (n: number) => Promise<T>, policy: RetryPolicy<T>): Promise<T> {
50
+ for (let n = 0; ; n++) {
51
+ let outcome: RetryOutcome<T>;
52
+ try {
53
+ outcome = { ok: true, value: await attempt(n) };
54
+ } catch (error) {
55
+ outcome = { ok: false, error };
56
+ }
57
+ const reason = n < policy.retries ? policy.retryReason(outcome) : null;
58
+ if (reason === null) {
59
+ if (outcome.ok) return outcome.value;
60
+ throw outcome.error;
61
+ }
62
+ if (outcome.ok && policy.discard) {
63
+ try {
64
+ await policy.discard(outcome.value);
65
+ } catch {
66
+ // It is being given up either way.
67
+ }
68
+ }
69
+ const delayMs = retryDelayMs(policy.schedule, n);
70
+ policy.onRetry?.(n + 1, policy.retries, delayMs, reason);
71
+ // The executor form: this package's lib (ES2022) has no Promise.withResolvers.
72
+ await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
73
+ }
74
+ }
package/src/utf8.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * utf8.ts — the UTF-8 length of a string, without encoding it.
3
+ *
4
+ * Bounds on names and payloads are in bytes, and the hot ones (every path a
5
+ * W7 wave or a listing page carries) were measured by encoding the string
6
+ * into a new buffer only to read its length. This counts the same bytes
7
+ * TextEncoder would write: one to four per code point, and three for a
8
+ * lone surrogate, which it writes as U+FFFD.
9
+ */
10
+ export function utf8Length(text: string): number {
11
+ let bytes = text.length;
12
+ for (let index = 0; index < text.length; index++) {
13
+ const unit = text.charCodeAt(index);
14
+ if (unit < 0x80) continue;
15
+ if (unit < 0x800) {
16
+ bytes += 1;
17
+ } else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) {
18
+ const next = text.charCodeAt(index + 1);
19
+ if (next >= 0xdc00 && next <= 0xdfff) {
20
+ // A pair: two units, four bytes.
21
+ bytes += 2;
22
+ index++;
23
+ } else {
24
+ bytes += 2;
25
+ }
26
+ } else {
27
+ bytes += 2;
28
+ }
29
+ }
30
+ return bytes;
31
+ }