@sproutboat/runtime 0.34.5 → 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 +23 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +120 -16
- package/src/native-fetch-prelude.js +313 -86
- package/src/transport-embedded.js +128 -31
- package/test/concurrent-driver.ts +291 -0
- package/test/fixtures/als-app.js +136 -0
- package/test/fixtures/concurrent-app.js +229 -0
- package/test/native-async-local-storage.ts +97 -0
- package/test/native-concurrent-requests.ts +87 -0
- package/test/native-cpu-requests.ts +156 -0
- package/test/native-d1.ts +36 -6
- package/test/native-headers.ts +182 -0
- package/test/native-sqlite-lock.ts +94 -0
|
@@ -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
|
+
}
|