@sproutboat/runtime 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.36.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7a9c1f9: `AsyncLocalStorage` (`import { AsyncLocalStorage } from "node:async_hooks"`, once the CLI maps the import), as Workers has it: `run`, `exit`, `getStore` and the static `snapshot`. `enterWith` and `disable` throw, as there. The static `bind` is not provided: Porffor compiles a `.bind(` call on a function value to `Function.prototype.bind`, so use `snapshot()`. The toolchain patches Porffor's job queue and timers to carry one current-context value: a promise reaction (then, await, a coroutine resume, a native response callback) and a timer record it when they are created and set it when they run, so the store follows a request across awaits, microtasks, timers, intervals, async fetch and WebSocket callbacks and `ctx.waitUntil`, and requests interleaved in one process never see each other's. Cost: a reaction is 56 bytes, not 40, and each job does one load. The context is only ever set by `AsyncLocalStorage`. Nothing an existing app observes changes, so there is no new compatibility date: `node:async_hooks` did not work at any date before (the import built and `new AsyncLocalStorage()` threw). New native test `test:native-async-local-storage` (needs `SB_CLI`).
8
+ - a68d923: `x-sb-cpu-ms` is per request when requests interleave in one process. A request is charged the thread CPU time of the slices in which it ran (the toolchain's async context carries a request id through awaits, reactions and timers, and the clock is read only when the running request changes, so a request that runs alone costs two reads). Before, the stamp was the process CPU delta across the handler, which is right only for a handler that answers synchronously, so an async handler was never stamped. From compatibility date `2026-11-02` an async handler is stamped too (its response is a string with no Set-Cookie, as for a sync one); earlier dates still send no stamp for an async handler. A synchronous handler's stamp is now thread time, which leaves out the egress threads. New native test `test:native-cpu-requests`: a 200 ms spinner among eight waiters is charged its 200 ms and the waiters under 60 ms, two spinners are each charged their own, and the date gate.
9
+
3
10
  ## 0.35.0
4
11
 
5
12
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3104,16 +3104,14 @@ function __sbCpuMs() {
3104
3104
  return ms;
3105
3105
  }
3106
3106
 
3107
- // The `x-sb-cpu-ms` value for the CPU time since `t0`, as three decimals, floored at zero: one
3108
- // clock read and one format call in C, where it was a read, a format, a parse and a toFixed.
3109
- // oxlint-disable-next-line no-unused-vars -- `t0` is read inside the RawC block below, not by JS.
3110
- function __sbCpuStamp(t0) {
3107
+ // The `x-sb-cpu-ms` value for a CPU time in ms, as three decimals, floored at zero: one format call
3108
+ // in C, where it was a format, a parse and a toFixed.
3109
+ // oxlint-disable-next-line no-unused-vars -- `cpu` is read inside the RawC block below, not by JS.
3110
+ function __sbCpuStamp(cpu) {
3111
3111
  let res = "0.000";
3112
3112
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
3113
3113
  Porffor.c`
3114
- struct timespec __ts;
3115
- clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
3116
- double __d = ((double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0) - (t0.type == 1 ? (double)t0.val : 0.0);
3114
+ double __d = cpu.type == 1 ? (double)cpu.val : 0.0;
3117
3115
  if (!(__d >= 0)) __d = 0;
3118
3116
  char __b[32];
3119
3117
  int __n = snprintf(__b, sizeof(__b), "%.3f", __d);
@@ -3122,6 +3120,33 @@ function __sbCpuStamp(t0) {
3122
3120
  return res;
3123
3121
  }
3124
3122
 
3123
+ // Per-request CPU (toolchain async-context.ts): a request is a small id whose thread CPU time is
3124
+ // summed over the slices in which it ran, however requests interleave. 0 when no slot is free.
3125
+ function __sbCpuBegin() {
3126
+ let id = 0;
3127
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
3128
+ Porffor.c`
3129
+ id = (f64)porf_cpu_begin();
3130
+ `;
3131
+ return id;
3132
+ }
3133
+ // oxlint-disable-next-line no-unused-vars -- `cell` is read inside the RawC block below, not by JS.
3134
+ function __sbCpuTake(cell) {
3135
+ let ms = 0;
3136
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
3137
+ Porffor.c`
3138
+ ms = porf_cpu_read_ms((u32)cell.val);
3139
+ `;
3140
+ return ms;
3141
+ }
3142
+ // oxlint-disable-next-line no-unused-vars -- `cell` is read inside the RawC block below, not by JS.
3143
+ function __sbCpuEnd(cell) {
3144
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
3145
+ Porffor.c`
3146
+ porf_cpu_end((u32)cell.val);
3147
+ `;
3148
+ }
3149
+
3125
3150
  // #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-10's
3126
3151
  // native-fetch serializer only reads headers from the plain object passed to
3127
3152
  // `new Response(body, { headers })` — a later `.set()` or a `Headers` instance
@@ -3129,7 +3154,7 @@ function __sbCpuStamp(t0) {
3129
3154
  // with one extra header. Safe only for a string body (the norm under
3130
3155
  // http-sync-v0) with no Set-Cookie to comma-fold; anything else is returned
3131
3156
  // untouched and the edge simply omits cpuMs for that request.
3132
- function __sbTagCpu(res, t0) {
3157
+ function __sbTagCpu(res, cpu) {
3133
3158
  try {
3134
3159
  // A string body and no Set-Cookie, as before. The native writer reads Headers' flat `_entries`,
3135
3160
  // so the stamp is appended to the handler's own Response instead of building a second one (a
@@ -3137,7 +3162,7 @@ function __sbTagCpu(res, t0) {
3137
3162
  // the handler set itself is replaced by the measured one, as before.
3138
3163
  const headers = res && res.headers;
3139
3164
  if (headers && __sbIsStr(__sbRawBody(res)) && !headers.has("set-cookie")) {
3140
- const stamp = __sbCpuStamp(t0);
3165
+ const stamp = __sbCpuStamp(cpu);
3141
3166
  const entries = headers._entries;
3142
3167
  let set = false;
3143
3168
  for (let i = 0; i < entries.length; i += 2) {
@@ -9160,7 +9185,7 @@ function __sbInstallWebSockets() {
9160
9185
  globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
9161
9186
  }
9162
9187
 
9163
- async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
9188
+ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell) {
9164
9189
  let r;
9165
9190
  try {
9166
9191
  r = res && __sbIsFn(res.then) ? await res : res;
@@ -9178,6 +9203,12 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
9178
9203
  // #179 — a rejected handler promise must not take the process down with it.
9179
9204
  r = __sbErrorResponse(error, bindingCall);
9180
9205
  }
9206
+ // From 2026-11-02 an async handler reports its CPU too: the thread time of the slices in which this
9207
+ // request ran, not its neighbours' (before it, only a handler that answered synchronously did).
9208
+ if (cpuCell) {
9209
+ if (__sbCompatDate() >= "2026-11-02") __sbTagCpu(r, __sbCpuTake(cpuCell));
9210
+ __sbCpuEnd(cpuCell);
9211
+ }
9181
9212
  // The response goes out now and ctx.waitUntil work keeps running after it, as in
9182
9213
  // workerd; it was awaited first, so a 1.5 s background task delayed the response by
9183
9214
  // 1.5 s. The 25 s cap still bounds it. Not called for no tasks: an async call is a coroutine and a promise.
@@ -9287,6 +9318,87 @@ globalThis.__sbOnUnhandledRejection = function (reason, promise) {
9287
9318
  }
9288
9319
  };
9289
9320
 
9321
+ // AsyncLocalStorage (node:async_hooks, as Workers has it: run, getStore, exit and the static snapshot
9322
+ // (no bind, enterWith or disable). The patched Porffor (toolchain async-context.ts) keeps one
9323
+ // "current context" value that every promise reaction, await and timer records when it is created and
9324
+ // sets when it runs. Here that value is a frame: the stores of the AsyncLocalStorage instances
9325
+ // entered so far, in two parallel arrays (a frame is never changed, run() makes a new one).
9326
+ function __sbCtxGet() {
9327
+ let frame;
9328
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
9329
+ Porffor.c`
9330
+ frame = porf_unpack(porf_async_ctx_bits);
9331
+ `;
9332
+ return frame;
9333
+ }
9334
+ // oxlint-disable-next-line no-unused-vars -- `frame` is read inside the RawC block below, not by JS.
9335
+ function __sbCtxSet(frame) {
9336
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
9337
+ Porffor.c`
9338
+ porf_async_ctx_bits = porf_pack(frame);
9339
+ `;
9340
+ }
9341
+ function __sbFrameIndex(frame, als) {
9342
+ if (!frame) return -1;
9343
+ const keys = frame.keys;
9344
+ for (let i = 0; i < keys.length; i++) if (keys[i] === als) return i;
9345
+ return -1;
9346
+ }
9347
+ function __sbFrameWith(frame, als, store) {
9348
+ const keys = [];
9349
+ const vals = [];
9350
+ if (frame) {
9351
+ for (let i = 0; i < frame.keys.length; i++) {
9352
+ if (frame.keys[i] === als) continue;
9353
+ keys.push(frame.keys[i]);
9354
+ vals.push(frame.vals[i]);
9355
+ }
9356
+ }
9357
+ keys.push(als);
9358
+ vals.push(store);
9359
+ return { keys, vals };
9360
+ }
9361
+ function __sbRunInFrame(frame, fn, args) {
9362
+ const previous = __sbCtxGet();
9363
+ __sbCtxSet(frame);
9364
+ try {
9365
+ return fn.apply(undefined, args);
9366
+ } finally {
9367
+ __sbCtxSet(previous);
9368
+ }
9369
+ }
9370
+ // A function constructor, not a class: Porffor refuses `new` on a class reached through an alias
9371
+ // (const { AsyncLocalStorage } = globalThis), which is how a bundled import arrives.
9372
+ function __SbAsyncLocalStorage() {}
9373
+ __SbAsyncLocalStorage.prototype.run = function (store, fn, ...args) {
9374
+ if (!__sbIsFn(fn)) throw new TypeError("The \"callback\" argument must be of type function.");
9375
+ return __sbRunInFrame(__sbFrameWith(__sbCtxGet(), this, store), fn, args);
9376
+ };
9377
+ __SbAsyncLocalStorage.prototype.exit = function (fn, ...args) {
9378
+ if (!__sbIsFn(fn)) throw new TypeError("The \"callback\" argument must be of type function.");
9379
+ return __sbRunInFrame(__sbFrameWith(__sbCtxGet(), this, undefined), fn, args);
9380
+ };
9381
+ __SbAsyncLocalStorage.prototype.getStore = function () {
9382
+ const frame = __sbCtxGet();
9383
+ const at = __sbFrameIndex(frame, this);
9384
+ return at === -1 ? undefined : frame.vals[at];
9385
+ };
9386
+ __SbAsyncLocalStorage.prototype.enterWith = function () {
9387
+ throw new Error("AsyncLocalStorage.enterWith() is not supported");
9388
+ };
9389
+ __SbAsyncLocalStorage.prototype.disable = function () {
9390
+ throw new Error("AsyncLocalStorage.disable() is not supported");
9391
+ };
9392
+ // AsyncLocalStorage.bind(fn) is not provided: Porffor compiles a call of .bind on a function value to
9393
+ // Function.prototype.bind whatever properties the function has. snapshot() does the same job.
9394
+ __SbAsyncLocalStorage.snapshot = function () {
9395
+ const frame = __sbCtxGet();
9396
+ return function (fn, ...args) {
9397
+ return __sbRunInFrame(frame, fn, args);
9398
+ };
9399
+ };
9400
+ globalThis.AsyncLocalStorage = __SbAsyncLocalStorage;
9401
+
9290
9402
  // Shared by alarm and scheduled: both reply with an empty 204 once their
9291
9403
  // handler (and anything it queued via `waitUntil`) has settled.
9292
9404
  async function __sb204WithDrain(res, tasks) {
@@ -9509,7 +9621,8 @@ globalThis.__sbEntry = function (handlers, request) {
9509
9621
  // itself returned, but never one derived from `.then()`, so chaining the
9510
9622
  // tag on hangs the request forever. cpuMs is documented as absent for
9511
9623
  // async handlers (see LogEvent in services/edge) — that is this.
9512
- const __t0 = __sbCpuMs();
9624
+ const __cpuCell = __sbCpuBegin();
9625
+ const __t0 = __cpuCell ? 0 : __sbCpuMs();
9513
9626
  const __sbTasks = [];
9514
9627
  const ctx = {
9515
9628
  waitUntil(p) {
@@ -9531,14 +9644,21 @@ globalThis.__sbEntry = function (handlers, request) {
9531
9644
  } catch (error) {
9532
9645
  // #179 — a synchronous throw anywhere in the handler must not take the
9533
9646
  // whole process (and every other in-flight request) down with it.
9647
+ __sbCpuEnd(__cpuCell);
9534
9648
  return __sbErrorResponse(error, bindingCall);
9535
9649
  }
9536
9650
  // Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
9537
9651
  // own return, which is why it's tail-called rather than chained on here.
9538
- if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall, false, true);
9652
+ if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall, false, true, __cpuCell);
9539
9653
  // A WebSocket handshake answer (from 2026-10-30): its client end becomes the host socket.
9540
- if (__sbHasWebSocket(__res)) return __sbWsHostResponse(__res);
9541
- return __sbTagCpu(__res, __t0);
9654
+ if (__sbHasWebSocket(__res)) {
9655
+ __sbCpuEnd(__cpuCell);
9656
+ return __sbWsHostResponse(__res);
9657
+ }
9658
+ // The request ran alone from start to here: its own slices, or the process delta where no slot was free.
9659
+ const __cpu = __cpuCell ? __sbCpuTake(__cpuCell) : __sbCpuMs() - __t0;
9660
+ __sbCpuEnd(__cpuCell);
9661
+ return __sbTagCpu(__res, __cpu);
9542
9662
  }
9543
9663
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
9544
9664
 
@@ -0,0 +1,291 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ // Drives test/fixtures/concurrent-app.js with many interleaved requests and checks that every
4
+ // reply holds only its own request's values. Used by native-concurrent-requests.ts (a standalone
5
+ // binary) and by sproutd's smoke (a worker serving up to 8 requests at once).
6
+
7
+ /** An upstream the app's fetch() calls: answers after `ms`, echoing the id it was asked for. */
8
+ export function startUpstream() {
9
+ return Bun.serve({
10
+ port: 0,
11
+ hostname: "127.0.0.1",
12
+ async fetch(request) {
13
+ const url = new URL(request.url);
14
+ await Bun.sleep(Number(url.searchParams.get("ms") || 0));
15
+ return Response.json({ id: url.searchParams.get("id"), header: request.headers.get("x-rid") });
16
+ },
17
+ });
18
+ }
19
+
20
+ type Json = string | number | boolean | null | Json[] | { readonly [key: string]: Json };
21
+
22
+ let seed = 12345;
23
+ const rand = (n: number) => {
24
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
25
+ return seed % n;
26
+ };
27
+
28
+ export interface DriverOptions {
29
+ readonly base: string;
30
+ readonly host?: string;
31
+ readonly upstream?: string;
32
+ readonly requests: number;
33
+ readonly rounds: number;
34
+ /** Whether the host carries client WebSockets (a standalone binary answers 501). */
35
+ readonly clientSockets: boolean;
36
+ readonly store: boolean;
37
+ /** Most requests (and sockets) in flight at once; sproutd queues 32 per app and refuses the rest. Default: all. */
38
+ readonly limit?: number;
39
+ }
40
+
41
+ const randomId = (round: number, i: number) => `r${round}-${i}-${rand(1e9).toString(36)}`;
42
+
43
+ export async function drive(options: DriverOptions): Promise<{ requests: number; failures: string[] }> {
44
+ const { base, upstream, requests, rounds } = options;
45
+ const failures: string[] = [];
46
+ const fail = (msg: string) => failures.push(msg);
47
+ const hostHeader: Record<string, string> = options.host ? { host: options.host } : {};
48
+ const get = (path: string, rid: string, init: RequestInit = {}) =>
49
+ fetch(`${base}${path}`, {
50
+ ...init,
51
+ headers: { ...hostHeader, "x-rid": rid, cookie: `sid=${rid}` },
52
+ });
53
+ const expectOwn = (kind: string, rid: string, value: Json, expected: Json) => {
54
+ if (JSON.stringify(value) !== JSON.stringify(expected))
55
+ fail(`${kind} ${rid}: got ${JSON.stringify(value)}, wanted ${JSON.stringify(expected)}`);
56
+ };
57
+ let total = 0;
58
+
59
+ const one = async (round: number, i: number) => {
60
+ const rid = randomId(round, i);
61
+ const delays = [rand(8), rand(8), rand(8)].join(",");
62
+ total++;
63
+ switch (i % 10) {
64
+ case 0:
65
+ case 1: {
66
+ const alloc = i % 2 ? "&alloc=1" : "";
67
+ const up = upstream ? `&up=${encodeURIComponent(upstream)}` : "";
68
+ const r = await get(`/mix?d=${delays}${alloc}${up}`, rid);
69
+ const body: {
70
+ rid: string;
71
+ steps: {
72
+ rid: string;
73
+ cookie: string;
74
+ url: string;
75
+ method: string;
76
+ env: string;
77
+ ctx: boolean;
78
+ cf: boolean;
79
+ headers: boolean;
80
+ }[];
81
+ mine: { id: string; text: string; list: string };
82
+ extra: string;
83
+ xrid: string;
84
+ upstream?: { id: string; header: string };
85
+ } = await r.json();
86
+ expectOwn("mix.rid", rid, body.rid, rid);
87
+ expectOwn("mix.header", rid, r.headers.get("x-rid"), rid);
88
+ expectOwn("mix.echo", rid, r.headers.get("x-echo"), rid);
89
+ for (const s of body.steps) {
90
+ expectOwn("mix.step.rid", rid, s.rid, rid);
91
+ expectOwn("mix.step.cookie", rid, s.cookie, `sid=${rid}`);
92
+ expectOwn("mix.step.url", rid, new URL(s.url).searchParams.get("d"), delays);
93
+ expectOwn("mix.step.flags", rid, [s.method, s.env, s.ctx, s.cf, s.headers], ["GET", "hi", true, true, true]);
94
+ }
95
+ expectOwn("mix.mine", rid, [body.mine.id, body.mine.text], [rid, "t-" + rid]);
96
+ expectOwn("mix.list", rid, body.mine.list, Array.from({ length: 20 }, (_, k) => `${rid}:${k}`).join(","));
97
+ expectOwn("mix.extra", rid, [body.extra, body.xrid], [rid, rid]);
98
+ if (upstream) expectOwn("mix.upstream", rid, [body.upstream?.id, body.upstream?.header], [rid, rid]);
99
+ break;
100
+ }
101
+ case 2: {
102
+ const r = await get(`/promise?d=${delays}`, rid);
103
+ const body: {
104
+ rid: string;
105
+ all: string[];
106
+ raced: string;
107
+ settled: string[];
108
+ again: string;
109
+ } = await r.json();
110
+ expectOwn(
111
+ "promise",
112
+ rid,
113
+ [body.rid, body.all, body.raced, body.settled, body.again],
114
+ [rid, [rid, rid, rid], rid, ["e-" + rid, rid], rid],
115
+ );
116
+ break;
117
+ }
118
+ case 3: {
119
+ const r = await get(`/boom?d=${delays}`, rid);
120
+ if (r.status !== 500) fail(`boom ${rid}: status ${r.status}`);
121
+ const text = await r.text();
122
+ if (text.includes("boom-")) fail(`boom ${rid}: the error text reached the client: ${text}`);
123
+ break;
124
+ }
125
+ case 4: {
126
+ const r = await get(`/catch?d=${delays}`, rid);
127
+ const body: { rid: string; message: string; again: string } = await r.json();
128
+ expectOwn("catch", rid, [body.rid, body.message, body.again], [rid, "caught-" + rid, rid]);
129
+ break;
130
+ }
131
+ case 5: {
132
+ const r = await get(`/reject?d=${delays}`, rid);
133
+ const body: { rid: string; ok: boolean; again: string } = await r.json();
134
+ expectOwn("reject", rid, [r.status, body.rid, body.ok, body.again], [200, rid, true, rid]);
135
+ break;
136
+ }
137
+ case 6: {
138
+ // A request body that arrives in pieces while others are being served.
139
+ const pieces = 3 + rand(4);
140
+ const data: Uint8Array[] = [];
141
+ let sum = 0;
142
+ let bytes = 0;
143
+ for (let p = 0; p < pieces; p++) {
144
+ const chunk = new Uint8Array(1000 + rand(3000));
145
+ for (let k = 0; k < chunk.length; k++) chunk[k] = rand(256);
146
+ for (const b of chunk) sum = (sum + b) % 1000003;
147
+ bytes += chunk.length;
148
+ data.push(chunk);
149
+ }
150
+ const stream = new ReadableStream<Uint8Array>({
151
+ async pull(controller) {
152
+ const next = data.shift();
153
+ if (!next) return controller.close();
154
+ await Bun.sleep(1 + rand(5));
155
+ controller.enqueue(next);
156
+ },
157
+ });
158
+ // SAFETY: Bun accepts a streamed body with duplex: "half"; its RequestInit type does not list the field.
159
+ const r = await get(`/body`, rid, { method: "POST", body: stream, duplex: "half" } as RequestInit);
160
+ const body: { rid: string; bytes: number; sum: number; again: string } = await r.json();
161
+ expectOwn("body", rid, [body.rid, body.bytes, body.sum, body.again], [rid, bytes, sum, rid]);
162
+ break;
163
+ }
164
+ case 7: {
165
+ const r = await get(`/stream?d=${delays}`, rid);
166
+ const text = await r.text();
167
+ const lines = text.trim().split("\n");
168
+ expectOwn(
169
+ "stream",
170
+ rid,
171
+ lines,
172
+ Array.from({ length: 8 }, (_, k) => `${rid}:${k}:${rid}`),
173
+ );
174
+ break;
175
+ }
176
+ case 8: {
177
+ if (options.store && round % 2 === 0) {
178
+ const r = await get(`/store?d=${delays}`, rid);
179
+ const body: { rid: string; kv: string; row: string; again: string } = await r.json();
180
+ expectOwn("store", rid, [body.rid, body.kv, body.row, body.again], [rid, rid, "v-" + rid, rid]);
181
+ } else {
182
+ const r = await get(`/pair?d=${rand(4)}`, rid);
183
+ const body: { rid: string; log: string[] } = await r.json();
184
+ expectPair(rid, body);
185
+ }
186
+ break;
187
+ }
188
+ case 9: {
189
+ const text = "x".repeat(2000 + rand(5000)) + rid;
190
+ const r = await get(`/text?d=${delays}`, rid, { method: "POST", body: text });
191
+ const body: { rid: string; bytes: number; sum: number; again: string } = await r.json();
192
+ let sum = 0;
193
+ for (let k = 0; k < text.length; k++) sum = (sum + text.charCodeAt(k)) % 1000003;
194
+ expectOwn("text", rid, [body.rid, body.bytes, body.sum, body.again], [rid, text.length, sum, rid]);
195
+ break;
196
+ }
197
+ }
198
+ if (i % 10 === 0) {
199
+ // ctx.waitUntil ran with this request's values, whatever ran in between.
200
+ await Bun.sleep(40);
201
+ const w: {
202
+ rid: string;
203
+ again: string;
204
+ mine: string;
205
+ ctx: boolean;
206
+ } | null = await (await get(`/wu`, rid)).json();
207
+ expectOwn("waitUntil", rid, w, { rid, again: rid, mine: rid, ctx: true });
208
+ }
209
+ };
210
+
211
+ const expectPair = (rid: string, body: { rid: string; log: string[] }) => {
212
+ const want: string[] = [];
213
+ for (let k = 0; k < 4; k++) want.push(`s:${rid}:${k}`, `c:echo:${rid}:${k}:${rid}`);
214
+ const sorted = (a: string[]) => [...a].sort();
215
+ expectOwn("pair", rid, [body.rid, sorted(body.log)], [rid, sorted(want)]);
216
+ };
217
+
218
+ const socket = async (round: number, i: number) => {
219
+ const rid = randomId(round, i);
220
+ total++;
221
+ // SAFETY: Bun takes a headers option on the WebSocket constructor; the DOM type does not list it.
222
+ const ws = new WebSocket(`${base.replace("http:", "ws:")}/ws?d=${rand(6)}`, {
223
+ headers: { ...hostHeader, "x-rid": rid },
224
+ } as never);
225
+ const got: string[] = [];
226
+ await new Promise<void>((done) => {
227
+ const timer = setTimeout(() => {
228
+ fail(`ws ${rid}: timed out with ${got.join(",")}`);
229
+ done();
230
+ }, 15000);
231
+ ws.addEventListener("open", () => {
232
+ for (let k = 0; k < 4; k++) ws.send(`m${k}`);
233
+ });
234
+ ws.addEventListener("message", (e) => {
235
+ got.push(String(e.data));
236
+ if (got.length === 4) {
237
+ clearTimeout(timer);
238
+ ws.close(1000, "done");
239
+ done();
240
+ }
241
+ });
242
+ ws.addEventListener("error", () => {
243
+ clearTimeout(timer);
244
+ fail(`ws ${rid}: error`);
245
+ done();
246
+ });
247
+ });
248
+ expectOwn(
249
+ "ws",
250
+ rid,
251
+ got.sort(),
252
+ [0, 1, 2, 3].map((k) => `${rid}|m${k}|${rid}`),
253
+ );
254
+ };
255
+
256
+ for (let round = 0; round < rounds; round++) {
257
+ const work: (() => Promise<void>)[] = [];
258
+ for (let i = 0; i < requests; i++) {
259
+ work.push(() =>
260
+ one(round, i).catch((error) => {
261
+ fail(`request ${round}-${i} threw ${String(error)}`);
262
+ }),
263
+ );
264
+ }
265
+ if (options.clientSockets) {
266
+ for (let i = 0; i < 6; i++)
267
+ work.push(() => socket(round, i).catch((error) => fail(`socket ${round}-${i} threw ${String(error)}`)));
268
+ }
269
+ // At most `limit` at a time, started in order.
270
+ let next = 0;
271
+ const runner = async () => {
272
+ while (next < work.length) {
273
+ const job = work[next++];
274
+ if (job) await job();
275
+ }
276
+ };
277
+ await Promise.all(Array.from({ length: Math.min(options.limit ?? work.length, work.length) }, runner));
278
+ }
279
+ // Nothing shared between requests: no two saw the same cf, headers or ctx object.
280
+ const report: {
281
+ maxActive: number;
282
+ cfShared: boolean;
283
+ headersShared: boolean;
284
+ ctxShared: boolean;
285
+ } = await (await get("/report", "report")).json();
286
+ if (report.cfShared || report.headersShared || report.ctxShared)
287
+ fail(`objects shared across requests: ${JSON.stringify(report)}`);
288
+ if (report.maxActive < 2) fail(`requests never overlapped (max ${report.maxActive} at once)`);
289
+ assert(Array.isArray(failures));
290
+ return { requests: total, failures };
291
+ }
@@ -0,0 +1,136 @@
1
+ // AsyncLocalStorage as Workers has it (nodejs_compat). The same file runs on workerd for the
2
+ // comparison, where the class comes from node:async_hooks; here the prelude's global stands in
3
+ // until the CLI maps the import.
4
+ const ALS = globalThis.__sbAlsForTest || globalThis.AsyncLocalStorage;
5
+ const als = new ALS();
6
+ const other = new ALS();
7
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
+
9
+ async function run(path, id, delay) {
10
+ if (path === "/basic") {
11
+ const out = [];
12
+ out.push(als.getStore());
13
+ out.push(als.run("a", () => als.getStore()));
14
+ out.push(als.run("a", () => als.run("b", () => als.getStore())));
15
+ out.push(als.run("a", () => als.run("b", () => als.exit(() => als.getStore()))));
16
+ out.push(als.run("a", () => other.run("x", () => [als.getStore(), other.getStore()])));
17
+ out.push(als.getStore());
18
+ try {
19
+ als.run("t", () => {
20
+ throw new Error("boom");
21
+ });
22
+ } catch (e) {
23
+ out.push(["threw", e.message, als.getStore()]);
24
+ }
25
+ out.push(als.run("args", (x, y) => [als.getStore(), x, y], 1, 2));
26
+ out.push(als.run("v", () => 7));
27
+ return out;
28
+ }
29
+ if (path === "/await") {
30
+ return als.run({ id }, async () => {
31
+ const seen = [als.getStore().id];
32
+ await sleep(delay);
33
+ seen.push(als.getStore().id);
34
+ await null;
35
+ seen.push(als.getStore().id);
36
+ await Promise.resolve().then(() => seen.push(als.getStore().id));
37
+ await new Promise((resolve) =>
38
+ setTimeout(() => {
39
+ seen.push(als.getStore().id);
40
+ resolve();
41
+ }, delay),
42
+ );
43
+ await Promise.all([
44
+ sleep(delay),
45
+ (async () => {
46
+ await sleep(1);
47
+ seen.push(als.getStore().id);
48
+ })(),
49
+ ]);
50
+ const viaThen = await new Promise((resolve) => {
51
+ sleep(delay).then(() => resolve(als.getStore().id));
52
+ });
53
+ seen.push(viaThen);
54
+ queueMicrotask(() => seen.push("q" + als.getStore().id));
55
+ await sleep(1);
56
+ let ticks = 0;
57
+ await new Promise((resolve) => {
58
+ const timer = setInterval(() => {
59
+ seen.push("i" + als.getStore().id);
60
+ if (++ticks === 2) {
61
+ clearInterval(timer);
62
+ resolve();
63
+ }
64
+ }, 1);
65
+ });
66
+ try {
67
+ await Promise.reject(new Error("r"));
68
+ } catch (e) {
69
+ seen.push("c" + als.getStore().id);
70
+ }
71
+ await Promise.allSettled([Promise.reject(new Error("s"))]);
72
+ seen.push("s" + als.getStore().id);
73
+ return seen;
74
+ });
75
+ }
76
+ if (path === "/bind") {
77
+ const snapped = als.run("captured", () => ALS.snapshot());
78
+ const bound = () => snapped(() => als.getStore());
79
+ const snap = als.run("snap", () => ALS.snapshot());
80
+ const results = [bound(), als.run("other", () => bound()), snap(() => als.getStore()), als.getStore()];
81
+ await sleep(1);
82
+ results.push(
83
+ await als.run("later", async () => {
84
+ await sleep(1);
85
+ return bound();
86
+ }),
87
+ );
88
+ return results;
89
+ }
90
+ if (path === "/nested-async") {
91
+ const inner = async () => {
92
+ await sleep(delay);
93
+ return als.getStore();
94
+ };
95
+ const a = als.run("outer", async () => {
96
+ const x = await inner();
97
+ const y = await als.run("inner", () => inner());
98
+ const z = await inner();
99
+ return [x, y, z, als.getStore()];
100
+ });
101
+ return await a;
102
+ }
103
+ if (path === "/leak") {
104
+ // A run() that finished leaves nothing behind, even while its async work is pending.
105
+ let pending;
106
+ als.run("gone", () => {
107
+ pending = sleep(delay);
108
+ });
109
+ const before = als.getStore();
110
+ await pending;
111
+ return [before, als.getStore()];
112
+ }
113
+ return null;
114
+ }
115
+
116
+ export default {
117
+ async fetch(request, env, ctx) {
118
+ const url = new URL(request.url);
119
+ const id = url.searchParams.get("id") || "";
120
+ const delay = Number(url.searchParams.get("d") || 1);
121
+ if (url.pathname === "/wu") {
122
+ als.run({ id }, () => {
123
+ ctx.waitUntil(
124
+ (async () => {
125
+ await sleep(delay);
126
+ globalThis.__alsWu = als.getStore().id;
127
+ })(),
128
+ );
129
+ });
130
+ return new Response("scheduled");
131
+ }
132
+ if (url.pathname === "/wu-read") return new Response(String(globalThis.__alsWu));
133
+ const result = await run(url.pathname, id, delay);
134
+ return Response.json(result);
135
+ },
136
+ };
@@ -0,0 +1,229 @@
1
+ // The app under test in native-concurrent-requests.ts: every route keeps its own request's
2
+ // values in locals, awaits on timers, fetch(), body streams, KV, D1 and WebSockets, and
3
+ // reports what it saw before and after each await. A reply that holds another request's id
4
+ // is a leak between interleaved requests. Written to the subset every host compiles.
5
+ const seenCf = [];
6
+ const seenHeaders = [];
7
+ const seenCtx = [];
8
+ const wakeups = {};
9
+ let active = 0;
10
+ let maxActive = 0;
11
+ let dbReady = false;
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+
14
+ async function handle(request, env, ctx) {
15
+ {
16
+ const url = new URL(request.url);
17
+ const path = url.pathname;
18
+ const rid = request.headers.get("x-rid") || url.searchParams.get("id") || "";
19
+ const delays = (url.searchParams.get("d") || "3,3,3").split(",").map(Number);
20
+ const snap = () => ({
21
+ rid: request.headers.get("x-rid"),
22
+ cookie: request.headers.get("cookie"),
23
+ url: request.url,
24
+ method: request.method,
25
+ env: env.GREETING,
26
+ ctx: ctx === myCtx,
27
+ cf: request.cf === myCf,
28
+ headers: request.headers === myHeaders,
29
+ });
30
+ const myCtx = ctx;
31
+ const myCf = request.cf;
32
+ const myHeaders = request.headers;
33
+ seenCf.push(myCf);
34
+ seenHeaders.push(myHeaders);
35
+ seenCtx.push(ctx);
36
+
37
+ if (path === "/report") {
38
+ return Response.json({
39
+ n: seenCf.length,
40
+ maxActive,
41
+ cfShared: new Set(seenCf).size !== seenCf.length,
42
+ headersShared: new Set(seenHeaders).size !== seenHeaders.length,
43
+ ctxShared: new Set(seenCtx).size !== seenCtx.length,
44
+ });
45
+ }
46
+ if (path === "/wu") return Response.json(wakeups[rid] || null);
47
+
48
+ if (path === "/mix") {
49
+ // Locals that must survive every await and every collection in between.
50
+ const mine = { id: rid, list: [], text: "t-" + rid };
51
+ for (let i = 0; i < 20; i++) mine.list.push(rid + ":" + i);
52
+ const out = { rid, steps: [snap()] };
53
+ const extra = new Headers(request.headers);
54
+ extra.set("x-mine", rid);
55
+ ctx.waitUntil(
56
+ (async () => {
57
+ await sleep(5 + delays[0]);
58
+ wakeups[rid] = { rid, again: request.headers.get("x-rid"), mine: mine.id, ctx: ctx === myCtx };
59
+ })(),
60
+ );
61
+ for (let i = 0; i < delays.length; i++) {
62
+ await sleep(delays[i]);
63
+ if (url.searchParams.get("alloc")) {
64
+ // Garbage between awaits: forces collections while other requests are suspended.
65
+ let junk = [];
66
+ for (let k = 0; k < 40; k++) junk.push("j".repeat(2000 + k) + k);
67
+ junk = null;
68
+ }
69
+ out.steps.push(snap());
70
+ }
71
+ const upstream = url.searchParams.get("up");
72
+ if (upstream) {
73
+ const r = await fetch(upstream + "/echo?id=" + rid + "&ms=" + delays[0], { headers: { "x-rid": rid } });
74
+ out.upstream = await r.json();
75
+ out.steps.push(snap());
76
+ }
77
+ out.mine = { id: mine.id, text: mine.text, list: mine.list.join(",") };
78
+ out.extra = extra.get("x-mine");
79
+ out.xrid = extra.get("x-rid");
80
+ return Response.json(out, { headers: { "x-rid": rid, "x-echo": rid } });
81
+ }
82
+
83
+ if (path === "/promise") {
84
+ // A promise returned from an async function and adopted by another, with timers between.
85
+ const inner = async () => {
86
+ await sleep(delays[0]);
87
+ return { rid, from: "inner", seen: request.headers.get("x-rid") };
88
+ };
89
+ const outer = async () => {
90
+ const v = await inner();
91
+ await sleep(delays[1] || 1);
92
+ return v;
93
+ };
94
+ const all = await Promise.all([outer(), outer(), inner()]);
95
+ const raced = await Promise.race([sleep(50).then(() => "slow"), sleep(delays[0]).then(() => rid)]);
96
+ const settled = await Promise.allSettled([Promise.reject(new Error("e-" + rid)), Promise.resolve(rid)]);
97
+ return Response.json({
98
+ rid,
99
+ all: all.map((x) => x.seen),
100
+ raced,
101
+ settled: [settled[0].reason.message, settled[1].value],
102
+ again: request.headers.get("x-rid"),
103
+ });
104
+ }
105
+
106
+ if (path === "/boom") {
107
+ await sleep(delays[0]);
108
+ throw new Error("boom-" + rid);
109
+ }
110
+ if (path === "/catch") {
111
+ try {
112
+ await sleep(delays[0]);
113
+ throw new Error("caught-" + rid);
114
+ } catch (error) {
115
+ await sleep(delays[1] || 1);
116
+ return Response.json({ rid, message: error.message, again: request.headers.get("x-rid") });
117
+ }
118
+ }
119
+ if (path === "/reject") {
120
+ // Nothing waits on this rejection: the request and its neighbours go on.
121
+ Promise.reject(new Error("unhandled-" + rid));
122
+ await sleep(delays[0]);
123
+ return Response.json({ rid, ok: true, again: request.headers.get("x-rid") });
124
+ }
125
+
126
+ if (path === "/body") {
127
+ // The body is read in pieces with a timer between each, other requests interleaving.
128
+ const reader = request.body.getReader();
129
+ let bytes = 0;
130
+ let sum = 0;
131
+ let pieces = 0;
132
+ for (;;) {
133
+ const { value, done } = await reader.read();
134
+ if (done) break;
135
+ pieces++;
136
+ bytes += value.length;
137
+ for (let i = 0; i < value.length; i++) sum = (sum + value[i]) % 1000003;
138
+ await sleep(1);
139
+ }
140
+ return Response.json({ rid, bytes, sum, pieces, again: request.headers.get("x-rid") });
141
+ }
142
+ if (path === "/text") {
143
+ const text = await request.text();
144
+ await sleep(delays[0]);
145
+ let sum = 0;
146
+ for (let i = 0; i < text.length; i++) sum = (sum + text.charCodeAt(i)) % 1000003;
147
+ return Response.json({ rid, bytes: text.length, sum, again: request.headers.get("x-rid") });
148
+ }
149
+
150
+ if (path === "/stream") {
151
+ let i = 0;
152
+ const n = 8;
153
+ const stream = new ReadableStream({
154
+ async pull(controller) {
155
+ await sleep(delays[0]);
156
+ if (i === n) {
157
+ controller.close();
158
+ return;
159
+ }
160
+ controller.enqueue(new TextEncoder().encode(rid + ":" + i + ":" + request.headers.get("x-rid") + "\n"));
161
+ i++;
162
+ },
163
+ });
164
+ return new Response(stream, { headers: { "content-type": "text/plain", "x-rid": rid } });
165
+ }
166
+
167
+ if (path === "/store") {
168
+ if (!dbReady) {
169
+ dbReady = true;
170
+ await env.DB.exec("CREATE TABLE IF NOT EXISTS t (rid TEXT, v TEXT)");
171
+ }
172
+ await env.KV.put("k-" + rid, rid);
173
+ await sleep(delays[0]);
174
+ const kv = await env.KV.get("k-" + rid);
175
+ await env.DB.prepare("INSERT INTO t (rid, v) VALUES (?, ?)")
176
+ .bind(rid, "v-" + rid)
177
+ .run();
178
+ await sleep(delays[1] || 1);
179
+ const row = await env.DB.prepare("SELECT v FROM t WHERE rid = ?").bind(rid).first();
180
+ return Response.json({ rid, kv, row: row && row.v, again: request.headers.get("x-rid") });
181
+ }
182
+
183
+ if (path === "/pair") {
184
+ // A WebSocketPair that stays in the worker: messages with timers between them.
185
+ const [client, server] = Object.values(new WebSocketPair());
186
+ server.accept();
187
+ client.accept();
188
+ const log = [];
189
+ server.addEventListener("message", (e) => {
190
+ log.push("s:" + e.data);
191
+ server.send("echo:" + e.data + ":" + request.headers.get("x-rid"));
192
+ });
193
+ client.addEventListener("message", (e) => log.push("c:" + e.data));
194
+ for (let i = 0; i < 4; i++) {
195
+ client.send(rid + ":" + i);
196
+ await sleep(delays[0]);
197
+ }
198
+ await sleep(5);
199
+ client.close(1000, rid);
200
+ return Response.json({ rid, log });
201
+ }
202
+
203
+ if (path === "/ws") {
204
+ // A client socket (hosts that carry them): each message is answered after a timer.
205
+ if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
206
+ const [client, server] = Object.values(new WebSocketPair());
207
+ server.accept();
208
+ server.addEventListener("message", async (e) => {
209
+ await sleep(delays[0]);
210
+ server.send(rid + "|" + e.data + "|" + request.headers.get("x-rid"));
211
+ });
212
+ return new Response(null, { status: 101, webSocket: client });
213
+ }
214
+
215
+ return new Response("ok " + rid);
216
+ }
217
+ }
218
+
219
+ export default {
220
+ async fetch(request, env, ctx) {
221
+ active++;
222
+ if (active > maxActive) maxActive = active;
223
+ try {
224
+ return await handle(request, env, ctx);
225
+ } finally {
226
+ active--;
227
+ }
228
+ },
229
+ };
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // AsyncLocalStorage (design 02, milestone 2): the store follows the control flow across
7
+ // awaits, promise reactions, timers, intervals, microtasks and ctx.waitUntil, and many
8
+ // requests interleaved in one process each see only their own. The expected values are what
9
+ // workerd (nodejs_compat) answers for test/fixtures/als-app.js.
10
+ const cli = process.env.SB_CLI;
11
+ if (!cli) {
12
+ console.log("skipped: set SB_CLI to a sproutboat-cli checkout linked to this checkout (it builds the sprouts)");
13
+ process.exit(0);
14
+ }
15
+
16
+ function freePort(): number {
17
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
18
+ const port = listener.port;
19
+ listener.stop(true);
20
+ return port;
21
+ }
22
+
23
+ const workdir = mkdtempSync(join(tmpdir(), "sb-native-als-"));
24
+ let server: ReturnType<typeof Bun.spawn> | undefined;
25
+ try {
26
+ const project = join(workdir, "app");
27
+ mkdirSync(join(project, "src"), { recursive: true });
28
+ writeFileSync(join(project, "src/index.js"), readFileSync(new URL("./fixtures/als-app.js", import.meta.url)));
29
+ writeFileSync(
30
+ join(project, "sproutboat.jsonc"),
31
+ JSON.stringify({ name: "als", main: "src/index.js", compatibility_date: process.env.SB_COMPAT ?? "2026-11-01" }),
32
+ );
33
+ const built = Bun.spawnSync(["bun", join(cli, "src/main.ts"), "build", "--standalone", "--target", "host"], {
34
+ cwd: project,
35
+ stdout: "inherit",
36
+ stderr: "inherit",
37
+ });
38
+ assert.equal(built.exitCode, 0, "the CLI could not build the sprout");
39
+ const port = freePort();
40
+ server = Bun.spawn([join(project, "dist/als")], {
41
+ cwd: project,
42
+ env: { ...process.env, PORT: String(port), SB_DATA_DIR: join(project, "data") },
43
+ stdout: "ignore",
44
+ stderr: "inherit",
45
+ });
46
+ const base = `http://127.0.0.1:${port}`;
47
+ let ready = false;
48
+ for (let attempt = 0; attempt < 100 && !ready; attempt++) {
49
+ try {
50
+ await fetch(`${base}/none`, { signal: AbortSignal.timeout(500) });
51
+ ready = true;
52
+ } catch {
53
+ if (server.exitCode !== null) break;
54
+ await Bun.sleep(100);
55
+ }
56
+ }
57
+ assert(ready, "the standalone app did not start");
58
+ const get = async (path: string) => (await fetch(`${base}${path}`)).json();
59
+
60
+ assert.deepEqual(await get("/basic"), [
61
+ null,
62
+ "a",
63
+ "b",
64
+ null,
65
+ ["a", "x"],
66
+ null,
67
+ ["threw", "boom", null],
68
+ ["args", 1, 2],
69
+ 7,
70
+ ]);
71
+ const steps = (id: string) => [id, id, id, id, id, id, id, `q${id}`, `i${id}`, `i${id}`, `c${id}`, `s${id}`];
72
+ assert.deepEqual(await get("/await?id=one&d=2"), steps("one"));
73
+ assert.deepEqual(await get("/bind"), ["captured", "captured", "snap", null, "captured"]);
74
+ assert.deepEqual(await get("/nested-async?d=2"), ["outer", "inner", "outer", "outer"]);
75
+ assert.deepEqual(await get("/leak?d=2"), [null, null]);
76
+ await (await fetch(`${base}/wu?id=wu1&d=20`)).text();
77
+ await Bun.sleep(100);
78
+ assert.equal(await (await fetch(`${base}/wu-read`)).text(), "wu1");
79
+ console.log(
80
+ "run, exit and snapshot behave as workerd's; the store follows awaits, timers, intervals, microtasks and waitUntil",
81
+ );
82
+
83
+ // Many requests at once, each with its own store and its own delays.
84
+ const ids = Array.from({ length: 60 }, (_, i) => `req${i}`);
85
+ const answers = await Promise.all(ids.map((id, i) => get(`/await?id=${id}&d=${1 + (i % 7)}`)));
86
+ ids.forEach((id, i) => assert.deepEqual(answers[i], steps(id), `request ${id} saw another request's store`));
87
+ const nested = await Promise.all(ids.map((id, i) => get(`/nested-async?id=${id}&d=${1 + (i % 5)}`)));
88
+ nested.forEach((answer) => assert.deepEqual(answer, ["outer", "inner", "outer", "outer"]));
89
+ assert.equal(server.exitCode, null, "the server exited");
90
+ console.log("60 interleaved requests each saw only their own store at every await");
91
+ } finally {
92
+ if (server) {
93
+ server.kill(9);
94
+ await server.exited;
95
+ }
96
+ rmSync(workdir, { recursive: true, force: true });
97
+ }
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { drive, startUpstream } from "./concurrent-driver";
6
+
7
+ // Concurrent requests in one process (design 02, milestone 1). A standalone binary serves many
8
+ // requests at once: they interleave on timers, request and response body streams, KV, D1 and
9
+ // in-worker WebSockets, and each reply must hold only its own request's values (headers, body,
10
+ // cookie, env, ctx, cf, errors, ctx.waitUntil results). The same app and driver run against a
11
+ // sproutd worker (test/concurrency-smoke.py, which serves up to 8 requests per process).
12
+ const cli = process.env.SB_CLI;
13
+ if (!cli) {
14
+ console.log("skipped: set SB_CLI to a sproutboat-cli checkout linked to this checkout (it builds the sprouts)");
15
+ process.exit(0);
16
+ }
17
+
18
+ function freePort(): number {
19
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
20
+ const port = listener.port;
21
+ listener.stop(true);
22
+ return port;
23
+ }
24
+
25
+ const workdir = mkdtempSync(join(tmpdir(), "sb-native-concurrent-"));
26
+ const upstream = startUpstream();
27
+ let server: ReturnType<typeof Bun.spawn> | undefined;
28
+ try {
29
+ const project = join(workdir, "app");
30
+ mkdirSync(join(project, "src"), { recursive: true });
31
+ writeFileSync(join(project, "src/index.js"), readFileSync(new URL("./fixtures/concurrent-app.js", import.meta.url)));
32
+ writeFileSync(
33
+ join(project, "sproutboat.jsonc"),
34
+ JSON.stringify({
35
+ name: "conc",
36
+ main: "src/index.js",
37
+ compatibility_date: process.env.SB_COMPAT ?? "2026-11-01",
38
+ vars: { GREETING: "hi" },
39
+ kv_namespaces: ["KV"],
40
+ d1_databases: ["DB"],
41
+ }),
42
+ );
43
+ const built = Bun.spawnSync(["bun", join(cli, "src/main.ts"), "build", "--standalone", "--target", "host"], {
44
+ cwd: project,
45
+ stdout: "inherit",
46
+ stderr: "inherit",
47
+ });
48
+ assert.equal(built.exitCode, 0, "the CLI could not build the sprout");
49
+ const port = freePort();
50
+ server = Bun.spawn([join(project, "dist/conc")], {
51
+ cwd: project,
52
+ env: { ...process.env, PORT: String(port), SB_EGRESS_ALLOW: "127.0.0.1", SB_DATA_DIR: join(project, "data") },
53
+ stdout: "ignore",
54
+ stderr: "ignore",
55
+ });
56
+ const base = `http://127.0.0.1:${port}`;
57
+ let ready = false;
58
+ for (let attempt = 0; attempt < 100 && !ready; attempt++) {
59
+ try {
60
+ await fetch(`${base}/ping`, { signal: AbortSignal.timeout(500) });
61
+ ready = true;
62
+ } catch {
63
+ if (server.exitCode !== null) break;
64
+ await Bun.sleep(100);
65
+ }
66
+ }
67
+ assert(ready, "the standalone app did not start");
68
+
69
+ const { requests, failures } = await drive({
70
+ base,
71
+ upstream: `http://127.0.0.1:${upstream.port}`,
72
+ requests: Number(process.env.SB_CONC_REQUESTS ?? 40),
73
+ rounds: Number(process.env.SB_CONC_ROUNDS ?? 5),
74
+ clientSockets: false,
75
+ store: true,
76
+ });
77
+ assert.equal(failures.length, 0, `cross-request leaks or failures:\n${failures.slice(0, 20).join("\n")}`);
78
+ assert.equal(server.exitCode, null, "the server exited");
79
+ console.log(`Standalone: ${requests} interleaved requests, no value crossed between them`);
80
+ } finally {
81
+ if (server) {
82
+ server.kill(9);
83
+ await server.exited;
84
+ }
85
+ upstream.stop(true);
86
+ rmSync(workdir, { recursive: true, force: true });
87
+ }
@@ -0,0 +1,156 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { ensurePorffor } from "../../toolchain/src/acquire";
6
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
7
+
8
+ // Per-request CPU (design 02): x-sb-cpu-ms counts the thread CPU time of the slices in which one
9
+ // request ran. A spinner interleaved with requests that wait is charged for its own work, the
10
+ // waiters for theirs, whatever order they run in. An async handler reports it from compatibility
11
+ // date 2026-11-02; before that only a handler that answers synchronously does.
12
+ const handler = `export default {async fetch(request) {
13
+ const url = new URL(request.url);
14
+ const ms = Number(url.searchParams.get("ms") || 0);
15
+ const sleep = (n) => new Promise((resolve) => setTimeout(resolve, n));
16
+ // Work, not wall time: a loaded machine deschedules the process, and CPU time must not follow wall time.
17
+ const spin = (n) => { let x = 1; for (let i = 0; i < n * 40000; i++) x = (x * 1.0000001 + i) % 1000003; return x; };
18
+ if (url.pathname === "/spin") {
19
+ await sleep(5);
20
+ spin(ms / 2);
21
+ await sleep(5);
22
+ spin(ms / 2);
23
+ return new Response("spun");
24
+ }
25
+ if (url.pathname === "/wait") {
26
+ await sleep(ms);
27
+ return new Response("waited");
28
+ }
29
+ if (url.pathname === "/sync") {
30
+ spin(ms);
31
+ return new Response("sync");
32
+ }
33
+ return new Response("none", { status: 404 });
34
+ }};`;
35
+
36
+ function freePort(): number {
37
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
38
+ const port = listener.port;
39
+ listener.stop(true);
40
+ return port;
41
+ }
42
+
43
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-cpu-requests-"));
44
+ const servers: ReturnType<typeof Bun.spawn>[] = [];
45
+ async function build(date: string): Promise<number> {
46
+ const porffor = await ensurePorffor();
47
+ const node = Bun.which("node");
48
+ if (!node) throw new Error("native cpu test needs node on PATH");
49
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
50
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
51
+ const generated = join(workdir, `cpu-${date}.generated.js`);
52
+ const binary = join(workdir, `cpu-${date}`);
53
+ await writeFile(
54
+ generated,
55
+ wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), undefined, undefined, undefined, date),
56
+ );
57
+ const compile = Bun.spawn(
58
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
59
+ {
60
+ cwd: workdir,
61
+ env: process.env,
62
+ stdout: "pipe",
63
+ stderr: "pipe",
64
+ },
65
+ );
66
+ const [exitCode, stdout, stderr] = await Promise.all([
67
+ compile.exited,
68
+ new Response(compile.stdout).text(),
69
+ new Response(compile.stderr).text(),
70
+ ]);
71
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
72
+ const port = freePort();
73
+ const server = Bun.spawn([binary], {
74
+ cwd: workdir,
75
+ env: { ...process.env, PORT: String(port) },
76
+ stdout: "ignore",
77
+ stderr: "inherit",
78
+ });
79
+ servers.push(server);
80
+ for (let attempt = 0; attempt < 50; attempt++) {
81
+ try {
82
+ await fetch(`http://127.0.0.1:${port}/none`, { signal: AbortSignal.timeout(500) });
83
+ return port;
84
+ } catch {
85
+ if (server.exitCode !== null) break;
86
+ await Bun.sleep(100);
87
+ }
88
+ }
89
+ throw new Error("compiled handler did not start");
90
+ }
91
+
92
+ try {
93
+ const port = await build("2026-11-02");
94
+ const cpu = async (path: string) => {
95
+ const response = await fetch(`http://127.0.0.1:${port}${path}`);
96
+ await response.text();
97
+ const header = response.headers.get("x-sb-cpu-ms");
98
+ assert(header !== null, `${path} has no x-sb-cpu-ms`);
99
+ return Number(header);
100
+ };
101
+
102
+ const alone = await cpu("/spin?ms=200");
103
+ assert(alone > 10, `a spinner alone reports only ${alone} ms`);
104
+ const sync = await cpu("/sync?ms=200");
105
+ assert(
106
+ sync > alone * 0.5 && sync < alone * 2,
107
+ `a synchronous spinner of the same work reports ${sync} ms, the async one ${alone}`,
108
+ );
109
+ const idle = await cpu("/wait?ms=50");
110
+ assert(idle < alone / 4 && idle < 30, `a request that only waits reports ${idle} ms (a spinner: ${alone})`);
111
+
112
+ // Together: the spinner next to eight waiters, repeated so the slices interleave differently.
113
+ for (let round = 0; round < 3; round++) {
114
+ const results = await Promise.all([
115
+ cpu("/spin?ms=200"),
116
+ ...Array.from({ length: 8 }, (_, i) => cpu(`/wait?ms=${150 + i * 20}`)),
117
+ ]);
118
+ const [spinner, ...waiters] = results;
119
+ assert(
120
+ spinner > alone * 0.5 && spinner < alone * 2,
121
+ `round ${round}: the spinner reports ${spinner} ms, alone it did ${alone}`,
122
+ );
123
+ for (const waiter of waiters)
124
+ assert(
125
+ waiter < alone / 4 && waiter < 60,
126
+ `round ${round}: a waiter was charged ${waiter} ms (the spinner: ${spinner})`,
127
+ );
128
+ }
129
+ // Two spinners at once are each charged their own work, not both.
130
+ const [a, b] = await Promise.all([cpu("/spin?ms=200"), cpu("/spin?ms=200")]);
131
+ assert(
132
+ a > alone * 0.5 && a < alone * 1.6 && b > alone * 0.5 && b < alone * 1.6,
133
+ `two spinners report ${a} and ${b} ms, one alone ${alone}`,
134
+ );
135
+ console.log(`per-request CPU holds under interleaving (a spinner ${alone.toFixed(0)} ms, waiters a few ms)`);
136
+
137
+ // Before 2026-11-02 an async handler still reports nothing.
138
+ const oldPort = await build("2026-11-01");
139
+ const old = await fetch(`http://127.0.0.1:${oldPort}/spin?ms=20`);
140
+ await old.text();
141
+ assert.equal(old.headers.get("x-sb-cpu-ms"), null, "an async handler is stamped before 2026-11-02");
142
+ const oldSync = await fetch(`http://127.0.0.1:${oldPort}/sync?ms=20`);
143
+ await oldSync.text();
144
+ assert.equal(
145
+ oldSync.headers.get("x-sb-cpu-ms"),
146
+ null,
147
+ "this handler is async, so it is not stamped before 2026-11-02",
148
+ );
149
+ console.log("before 2026-11-02 an async handler is not stamped");
150
+ } finally {
151
+ for (const server of servers) {
152
+ server.kill(9);
153
+ await server.exited;
154
+ }
155
+ await rm(workdir, { recursive: true, force: true });
156
+ }