@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
|
@@ -1195,6 +1195,17 @@ static unsigned long sb_db_tick = 0;
|
|
|
1195
1195
|
static int sb_is_do_sql(const char* path) { return strstr(path, "/do-sql/") != 0; }
|
|
1196
1196
|
static void sb_db_evict(int idx);
|
|
1197
1197
|
|
|
1198
|
+
// SQLite allocates 20 pages in one block for every page cache it creates, and a statement that
|
|
1199
|
+
// builds a temporary table (INSERT ... RETURNING, a sort) creates one: an 88 KiB mmap and munmap
|
|
1200
|
+
// per such statement. A page count of 0 turns the bulk allocation off; the cache then takes
|
|
1201
|
+
// its pages one at a time, as it always does past the first 20. Must run before SQLite starts.
|
|
1202
|
+
extern int sqlite3_config(int, ...);
|
|
1203
|
+
static int sb_sqlite_booted = 0;
|
|
1204
|
+
static void sb_sqlite_boot(void) {
|
|
1205
|
+
if (sb_sqlite_booted) return;
|
|
1206
|
+
sb_sqlite_booted = 1;
|
|
1207
|
+
sqlite3_config(7 /* SQLITE_CONFIG_PAGECACHE */, (void*)0, 0, 0);
|
|
1208
|
+
}
|
|
1198
1209
|
static int sb_db_for(const char* path) {
|
|
1199
1210
|
for (int i = 0; i < sb_db_count; i++) {
|
|
1200
1211
|
if (sb_dbs[i] && strcmp(sb_db_names[i], path) == 0) {
|
|
@@ -1202,6 +1213,7 @@ static int sb_db_for(const char* path) {
|
|
|
1202
1213
|
return sb_ltx_states[i].failed ? -1 : i;
|
|
1203
1214
|
}
|
|
1204
1215
|
}
|
|
1216
|
+
sb_sqlite_boot();
|
|
1205
1217
|
const int do_sql = sb_is_do_sql(path);
|
|
1206
1218
|
// A name that would not fit could never be found again.
|
|
1207
1219
|
if (do_sql && strlen(path) >= sizeof(sb_db_names[0])) return -1;
|
|
@@ -1240,9 +1252,30 @@ static int sb_db_for(const char* path) {
|
|
|
1240
1252
|
// copies of the binary behind SO_REUSEPORT (#154 scaling recipe): WAL takes
|
|
1241
1253
|
// concurrent readers plus one writer across processes, and this makes a
|
|
1242
1254
|
// writer wait for the lock instead of failing SQLITE_BUSY.
|
|
1255
|
+
// SB_SQLITE_EXCLUSIVE=1 holds the database's lock for the life of the process: no fcntl lock
|
|
1256
|
+
// and unlock around each transaction, and the WAL index lives in heap memory instead of a shared
|
|
1257
|
+
// -shm file. Set before journal_mode, because the heap index is only chosen when WAL starts in
|
|
1258
|
+
// exclusive mode. No other process can open the database while the binary runs, which also rules
|
|
1259
|
+
// out the several-copies-behind-SO_REUSEPORT recipe above. Off unless set (see below), and never for a
|
|
1260
|
+
// database under LTX replication: capture reads the WAL through its shared index, and in
|
|
1261
|
+
// exclusive mode (no -shm file) every capture failed (native-d1's LTX database).
|
|
1262
|
+
// The transport itself only reads the variable, so a host that embeds it (sproutd, the broker) stays
|
|
1263
|
+
// in shared mode unless it is set. A standalone binary sets it to 1 at startup (patch.ts, the shim's
|
|
1264
|
+
// constructor) unless the operator has set it, so SB_SQLITE_EXCLUSIVE=0 opts a standalone binary out.
|
|
1265
|
+
// SB_SQLITE_CACHE_MB sizes each database's page cache in MiB (default 2, about -2000 KiB).
|
|
1266
|
+
{
|
|
1267
|
+
const char* excl = getenv("SB_SQLITE_EXCLUSIVE");
|
|
1268
|
+
if (excl && excl[0] == '1' && !excl[1] && !replicated) sqlite3_exec(db, "PRAGMA locking_mode=EXCLUSIVE", 0, 0, 0);
|
|
1269
|
+
char cache[64];
|
|
1270
|
+
const char* mb = getenv("SB_SQLITE_CACHE_MB");
|
|
1271
|
+
long mib = mb && *mb ? strtol(mb, 0, 10) : 0;
|
|
1272
|
+
if (mib >= 1 && mib <= 4096) snprintf(cache, sizeof(cache), "PRAGMA cache_size=-%ld", mib * 1024);
|
|
1273
|
+
else snprintf(cache, sizeof(cache), "PRAGMA cache_size=-2000");
|
|
1274
|
+
sqlite3_exec(db, cache, 0, 0, 0);
|
|
1275
|
+
}
|
|
1243
1276
|
sqlite3_exec(db,
|
|
1244
1277
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;"
|
|
1245
|
-
"PRAGMA
|
|
1278
|
+
"PRAGMA journal_size_limit=16777216;",
|
|
1246
1279
|
0, 0, 0);
|
|
1247
1280
|
// Durable Object SQLite enforces foreign keys, as in workerd.
|
|
1248
1281
|
if (do_sql) sqlite3_exec(db, "PRAGMA foreign_keys=ON", 0, 0, 0);
|
|
@@ -2165,7 +2198,9 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
|
|
|
2165
2198
|
// The text and blob bytes of every cell share one arena (a cell holds an offset into it), and a
|
|
2166
2199
|
// TEXT cell says whether it is all ASCII: that one becomes a JS string with a single copy.
|
|
2167
2200
|
typedef struct { unsigned char t; unsigned char ascii; double d; size_t off; int n; } sb_cell;
|
|
2168
|
-
|
|
2201
|
+
// hdr: how many of the first cells are the column names, not row data; changes and rowid are the
|
|
2202
|
+
// statement's counts, so a native reply needs no JSON header at all.
|
|
2203
|
+
typedef struct { sb_cell* c; size_t n; size_t cap; int ncol; int used; char* a; size_t alen; size_t acap; int hdr; long long changes; long long rowid; } sb_cells;
|
|
2169
2204
|
static void sb_cells_free(sb_cells* cs) {
|
|
2170
2205
|
if (!cs) return;
|
|
2171
2206
|
free(cs->c);
|
|
@@ -2222,17 +2257,30 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
|
|
|
2222
2257
|
return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
|
|
2223
2258
|
}
|
|
2224
2259
|
|
|
2225
|
-
sb_puts(&b, "{\"ok\":true,\"cols\":[");
|
|
2226
2260
|
int ncol = sqlite3_column_count(st);
|
|
2227
|
-
for
|
|
2228
|
-
|
|
2229
|
-
const char* name = sqlite3_column_name(st, i);
|
|
2230
|
-
sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
|
|
2231
|
-
}
|
|
2232
|
-
sb_puts(&b, "],\"rows\":[");
|
|
2233
|
-
// Cells only for a strict D1 statement: its BLOBs are byte arrays either way.
|
|
2261
|
+
// Cells only for a strict D1 statement: its BLOBs are byte arrays either way. The column names
|
|
2262
|
+
// are its first cells and the reply is no JSON text: success returns NULL, with the counts in cells.
|
|
2234
2263
|
const int native = cells && strict && d1;
|
|
2235
|
-
if (native) {
|
|
2264
|
+
if (native) {
|
|
2265
|
+
cells->ncol = ncol; cells->used = 1; cells->hdr = ncol;
|
|
2266
|
+
for (int i = 0; i < ncol && cells->used > 0; i++) {
|
|
2267
|
+
sb_cell* cell = sb_cells_add(cells);
|
|
2268
|
+
if (!cell) { cells->used = -1; break; }
|
|
2269
|
+
const char* name = sqlite3_column_name(st, i);
|
|
2270
|
+
cell->t = 2;
|
|
2271
|
+
cell->n = name ? (int)strlen(name) : 0;
|
|
2272
|
+
cell->off = sb_cells_bytes(cells, (const unsigned char*)(name ? name : ""), (size_t)cell->n, &cell->ascii);
|
|
2273
|
+
if (cell->off == (size_t)-1) { cells->used = -1; break; }
|
|
2274
|
+
}
|
|
2275
|
+
} else {
|
|
2276
|
+
sb_puts(&b, "{\"ok\":true,\"cols\":[");
|
|
2277
|
+
for (int i = 0; i < ncol; i++) {
|
|
2278
|
+
if (i) sb_puts(&b, ",");
|
|
2279
|
+
const char* name = sqlite3_column_name(st, i);
|
|
2280
|
+
sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
|
|
2281
|
+
}
|
|
2282
|
+
sb_puts(&b, "],\"rows\":[");
|
|
2283
|
+
}
|
|
2236
2284
|
int rc, first = 1;
|
|
2237
2285
|
while ((rc = sqlite3_step(st)) == SB_SQLITE_ROW) {
|
|
2238
2286
|
if (native) {
|
|
@@ -2280,10 +2328,15 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
|
|
|
2280
2328
|
}
|
|
2281
2329
|
sb_puts(&b, "]");
|
|
2282
2330
|
}
|
|
2283
|
-
char tail[96];
|
|
2284
2331
|
// A read-only statement changed nothing: sqlite3_changes still holds the last write's count.
|
|
2285
|
-
|
|
2286
|
-
|
|
2332
|
+
if (native) {
|
|
2333
|
+
cells->changes = sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db);
|
|
2334
|
+
cells->rowid = (long long)sqlite3_last_insert_rowid(db);
|
|
2335
|
+
} else {
|
|
2336
|
+
char tail[96];
|
|
2337
|
+
int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
|
|
2338
|
+
sb_put(&b, tail, (size_t)k);
|
|
2339
|
+
}
|
|
2287
2340
|
// Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
|
|
2288
2341
|
// now (not just on next reuse) so a SELECT stops holding its read transaction.
|
|
2289
2342
|
sqlite3_reset(st);
|
|
@@ -4029,6 +4082,22 @@ function __sbDirBytesRaw(dir) {
|
|
|
4029
4082
|
return res;
|
|
4030
4083
|
}
|
|
4031
4084
|
|
|
4085
|
+
// "captured" of __sbD1DbRaw as a number: no text made in C and parsed here, on every D1 query.
|
|
4086
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4087
|
+
function __sbD1Captured_(path) {
|
|
4088
|
+
let status = -1;
|
|
4089
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4090
|
+
Porffor.c`
|
|
4091
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
4092
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
4093
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
4094
|
+
if (__po) free(__po);
|
|
4095
|
+
status = (f64)sb_db_quota_freeze(__path, -1);
|
|
4096
|
+
free(__path);
|
|
4097
|
+
`;
|
|
4098
|
+
return status;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4032
4101
|
// sb_db_page_size and sb_db_quota_freeze, for the quota (a path and a "0"/"1" in, a number out).
|
|
4033
4102
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4034
4103
|
function __sbD1DbRaw(path, what) {
|
|
@@ -4235,10 +4304,10 @@ function __sbSqlRaw(path, sql, paramsJson) {
|
|
|
4235
4304
|
return res;
|
|
4236
4305
|
}
|
|
4237
4306
|
|
|
4238
|
-
// One strict D1 statement: [header
|
|
4239
|
-
//
|
|
4240
|
-
//
|
|
4241
|
-
//
|
|
4307
|
+
// One strict D1 statement: [header, rows, columns, changes, rowid]. A result has the empty string
|
|
4308
|
+
// for its header, rows an array of row arrays built here from the cells (BLOBs as byte arrays),
|
|
4309
|
+
// columns the array of names, and changes and rowid numbers: no JSON is written or parsed. Any
|
|
4310
|
+
// other reply is the error JSON in the header, with nothing after it.
|
|
4242
4311
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4243
4312
|
function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
4244
4313
|
let res = "";
|
|
@@ -4262,21 +4331,28 @@ function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
|
4262
4331
|
sb_cells __cells = { 0, 0, 0, 0, 0 };
|
|
4263
4332
|
char* __out = sb_sql_run_x(__path, __sql, __args, &__cells);
|
|
4264
4333
|
free(__path); free(__sql); free(__args);
|
|
4265
|
-
const u32 __pair = porf_arr_new(
|
|
4334
|
+
const u32 __pair = porf_arr_new(5, 5);
|
|
4266
4335
|
res = porf_box((f64)__pair, 72);
|
|
4267
|
-
|
|
4268
|
-
|
|
4336
|
+
const int __native = !__out && __cells.used == 1;
|
|
4337
|
+
if (!__out && !__native) __out = strdup("{\"ok\":false,\"error\":\"no result\"}");
|
|
4338
|
+
porf_arr_set(__pair, 0, porf_box((f64)porf_native_fetch_alloc_bytestring(__out ? __out : "", __out ? strlen(__out) : 0), 195));
|
|
4269
4339
|
free(__out);
|
|
4270
|
-
if (
|
|
4340
|
+
if (__native) {
|
|
4271
4341
|
const size_t __ncol = (size_t)__cells.ncol;
|
|
4272
|
-
const size_t
|
|
4342
|
+
const size_t __hdr = (size_t)__cells.hdr;
|
|
4343
|
+
const size_t __nrow = __ncol ? (__cells.n - __hdr) / __ncol : 0;
|
|
4273
4344
|
const u32 __rows = porf_arr_new(0, (i32)(__nrow ? __nrow : 1));
|
|
4274
4345
|
porf_arr_set(__pair, 1, porf_box((f64)__rows, 72));
|
|
4275
|
-
|
|
4346
|
+
porf_arr_set(__pair, 3, porf_box_num((f64)__cells.changes));
|
|
4347
|
+
porf_arr_set(__pair, 4, porf_box_num((f64)__cells.rowid));
|
|
4348
|
+
if (!__ncol) porf_arr_set(__pair, 2, porf_box((f64)porf_arr_new(0, 1), 72));
|
|
4349
|
+
// Row index -1 is the column names; the data rows follow.
|
|
4350
|
+
for (long long __r = (__ncol ? -1 : 0); __r < (long long)__nrow; __r++) {
|
|
4276
4351
|
const u32 __row = porf_arr_new((i32)__ncol, (i32)__ncol);
|
|
4277
|
-
porf_arr_set(
|
|
4352
|
+
if (__r < 0) porf_arr_set(__pair, 2, porf_box((f64)__row, 72));
|
|
4353
|
+
else porf_arr_set(__rows, (u32)__r, porf_box((f64)__row, 72));
|
|
4278
4354
|
for (size_t __c = 0; __c < __ncol; __c++) {
|
|
4279
|
-
const sb_cell* __cell = &__cells.c[__r * __ncol + __c];
|
|
4355
|
+
const sb_cell* __cell = &__cells.c[(__r < 0 ? 0 : __hdr + (size_t)__r * __ncol) + __c];
|
|
4280
4356
|
jsval __v = porf_box(0.0, 7);
|
|
4281
4357
|
if (__cell->t == 1) __v = porf_box_num(__cell->d);
|
|
4282
4358
|
else if (__cell->t == 2 && __cell->ascii) {
|
|
@@ -4386,7 +4462,7 @@ function __sbD1Quota(path) {
|
|
|
4386
4462
|
// Whether capture owns this database, from the private helper (no SQL), before anything
|
|
4387
4463
|
// below depends on it. A database that cannot open (or whose capture failed) is left to
|
|
4388
4464
|
// the call itself, which reports why.
|
|
4389
|
-
const status =
|
|
4465
|
+
const status = __sbD1Captured_(path);
|
|
4390
4466
|
if (status < 0) return;
|
|
4391
4467
|
__sbD1Captured[path] = status === 1;
|
|
4392
4468
|
let full = __sbQuotaFull();
|
|
@@ -4434,8 +4510,16 @@ function __sbDOSqlPath(cls, file) {
|
|
|
4434
4510
|
if (!__sbDOSqlName(c, false) || !__sbDOSqlName(f, true)) throw new Error("invalid Durable Object SQL database");
|
|
4435
4511
|
return __sbDir() + "/do-sql/" + c + "/" + f + ".sqlite";
|
|
4436
4512
|
}
|
|
4513
|
+
// The last database's path: an app has one or two, and every query asks, so three concatenations
|
|
4514
|
+
// are done once. The directory cannot change mid-run (see __sbDir).
|
|
4515
|
+
var __sbD1PathName = "";
|
|
4516
|
+
var __sbD1PathValue = "";
|
|
4437
4517
|
function __sbD1Path(name) {
|
|
4438
|
-
|
|
4518
|
+
if (name === __sbD1PathName) return __sbD1PathValue;
|
|
4519
|
+
const path = __sbDir() + "/d1/" + name + ".sqlite";
|
|
4520
|
+
__sbD1PathName = name;
|
|
4521
|
+
__sbD1PathValue = path;
|
|
4522
|
+
return path;
|
|
4439
4523
|
}
|
|
4440
4524
|
|
|
4441
4525
|
function __sbSql(path, sql, params) {
|
|
@@ -5356,10 +5440,12 @@ function __sbD1Run(path, sql, params, strict) {
|
|
|
5356
5440
|
if (strict) {
|
|
5357
5441
|
// Rows come back as arrays, built in C (see __sbSqlRowsRaw); the header is small JSON.
|
|
5358
5442
|
const pair = __sbSqlRowsRaw(path, "/*sb-d1-strict*/" + String(sql), JSON.stringify(params || []));
|
|
5359
|
-
|
|
5360
|
-
|
|
5443
|
+
if (pair[0] !== "") {
|
|
5444
|
+
const head = JSON.parse(pair[0]);
|
|
5445
|
+
if (head.ok === false) throw __sbD1Error(head);
|
|
5446
|
+
}
|
|
5361
5447
|
const rows = pair[1];
|
|
5362
|
-
return { columns:
|
|
5448
|
+
return { columns: pair[2], rows, native: true, meta: { changes: pair[3], last_row_id: pair[4], rows_read: rows.length } };
|
|
5363
5449
|
}
|
|
5364
5450
|
const r = JSON.parse(__sbSqlRaw(path, (strict ? "/*sb-d1-strict*/" : "") + String(sql), JSON.stringify(params || [])));
|
|
5365
5451
|
if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
|
|
@@ -5524,6 +5610,17 @@ function __sbCall(reqJson) {
|
|
|
5524
5610
|
}
|
|
5525
5611
|
}
|
|
5526
5612
|
|
|
5613
|
+
// A strict D1 query without the generic dispatch: no request object, no copy of its fields, no walk
|
|
5614
|
+
// down the op list and no result object. The same steps __sbEmbeddedDispatch runs for "d1.query".
|
|
5615
|
+
__sbD1QueryDirect = function (db, sql, params) {
|
|
5616
|
+
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
|
5617
|
+
__sbEnsureSchema();
|
|
5618
|
+
const path = __sbD1Path(String(db));
|
|
5619
|
+
__sbD1Open(path, true);
|
|
5620
|
+
__sbD1Quota(path);
|
|
5621
|
+
return __sbD1Run(path, sql, params, true);
|
|
5622
|
+
};
|
|
5623
|
+
|
|
5527
5624
|
// The same dispatch without the JSON text either side: D1 calls take this (see __sbRpcDirect).
|
|
5528
5625
|
__sbCallDirect = function (req) {
|
|
5529
5626
|
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
|
@@ -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
|
+
}
|