@sproutboat/runtime 0.35.0 → 0.37.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/CHANGELOG.md +36 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +215 -0
- package/src/native-fetch-prelude.js +989 -87
- package/src/queues.test.js +136 -0
- package/src/transport-embedded.js +156 -35
- 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 +71 -0
- package/test/native-domexception.ts +114 -0
- package/test/native-getrandomvalues.ts +117 -0
- package/test/native-textdecoder-encodings.ts +123 -0
|
@@ -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
|
+
};
|