@sproutboat/runtime 0.44.0 → 0.46.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.
@@ -0,0 +1,244 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // The handle table of the embedded transport (plan 018 phase 1), compiled directly against the system SQLite:
7
+ // the slot LRU, the single close function, the cache sizing rules, the object-database slot count.
8
+ // A C driver runs one scenario per process (the table is process-global) and prints one JSON line.
9
+ // SB_TEST_SQLITE_DIR=DIR (sqlite3.h and a built sqlite3.o, the amalgamation the host uses) runs it against that instead;
10
+ // SB_DB_TABLE_BENCH=1 also prints the E7 timings (adaptive cache against a fixed 2 MiB).
11
+ const transport = await readFile(new URL("../src/transport-embedded.js", import.meta.url), "utf8");
12
+ const ltxStart = transport.indexOf("// BEGIN LTX NATIVE"),
13
+ ltxEnd = transport.indexOf("// END LTX NATIVE", ltxStart);
14
+ const tableStart = transport.indexOf("// BEGIN DB TABLE NATIVE"),
15
+ tableEnd = transport.indexOf("// END DB TABLE NATIVE", tableStart);
16
+ assert(ltxStart > 0 && ltxEnd > ltxStart && tableStart > ltxEnd && tableEnd > tableStart);
17
+ const work = await mkdtemp(join(tmpdir(), "sb-native-dbtable-"));
18
+ const source = `
19
+ #include <sqlite3.h>
20
+ #include <stdio.h>
21
+ #include <stdlib.h>
22
+ #include <stdint.h>
23
+ #include <string.h>
24
+ #include <strings.h>
25
+ #include <unistd.h>
26
+ #include <fcntl.h>
27
+ #include <sys/stat.h>
28
+ #include <time.h>
29
+ #include <errno.h>
30
+ // These four lines are transport-embedded.js's globals above the LTX block: keep them equal.
31
+ #define SB_MAX_DB 64
32
+ static sqlite3* sb_dbs[SB_MAX_DB];
33
+ static char sb_db_names[SB_MAX_DB][256];
34
+ static int sb_db_count = 0;
35
+ ${transport.slice(ltxStart, ltxEnd)}
36
+ ${transport.slice(tableStart, tableEnd)}
37
+ static char root[512];
38
+ static void path_of(char* out, const char* kind, int i) {
39
+ // object databases live under /do-sql/CLASS/, D1-style ones under /d1/
40
+ if (!strcmp(kind, "do")) snprintf(out, 600, "%s/do-sql/C/%064x.sqlite", root, i);
41
+ else snprintf(out, 600, "%s/d1/db%d.sqlite", root, i);
42
+ }
43
+ static void sql(int idx, const char* q) { char* e = 0; if (sqlite3_exec(sb_dbs[idx], q, 0, 0, &e)) { printf("{\\"error\\":\\"%s: %s\\"}\\n", q, e); exit(1); } }
44
+ static long scalar(int idx, const char* q) { sqlite3_stmt* st; long v = -1; if (!sqlite3_prepare_v2(sb_dbs[idx], q, -1, &st, 0)) { if (sqlite3_step(st) == 100) v = sqlite3_column_int64(st, 0); sqlite3_finalize(st); } return v; }
45
+ static void stats(void) { char b[600]; sb_db_stats(b, sizeof b); printf("{\\"stats\\":%s}\\n", b); }
46
+ static double ms_since(struct timespec* a) { struct timespec b; clock_gettime(CLOCK_MONOTONIC, &b); return ((b.tv_sec - a->tv_sec) * 1e9 + (b.tv_nsec - a->tv_nsec)) / 1e6; }
47
+ int main(int argc, char** argv) {
48
+ snprintf(root, sizeof root, "%s", argv[1]);
49
+ const char* scenario = argv[2];
50
+ char p[600];
51
+ sb_sqlite_boot();
52
+ if (!strcmp(scenario, "slots")) { printf("{\\"slots\\":%d,\\"targetKb\\":%ld}\\n", sb_do_sql_open, sb_cache_target_kb); return 0; }
53
+ if (!strcmp(scenario, "fifty")) {
54
+ // 50 object databases through the slots: each reads back what it wrote after being closed and reopened
55
+ int bad = 0, max_open = 0;
56
+ for (int i = 0; i < 50; i++) {
57
+ path_of(p, "do", i); int idx = sb_db_for(p); if (idx < 0) { printf("{\\"error\\":\\"open %d\\"}\\n", i); return 1; }
58
+ sql(idx, "CREATE TABLE IF NOT EXISTS t (v INTEGER)"); char q[64]; snprintf(q, sizeof q, "INSERT INTO t VALUES (%d)", i * 7); sql(idx, q);
59
+ int open = 0; for (int k = 0; k < sb_db_count; k++) if (sb_dbs[k]) open++; if (open > max_open) max_open = open;
60
+ }
61
+ for (int r = 0; r < 2; r++) for (int i = 49; i >= 0; i--) {
62
+ path_of(p, "do", i); int idx = sb_db_for(p); if (idx < 0 || scalar(idx, "SELECT v FROM t") != i * 7) bad++;
63
+ }
64
+ stats(); printf("{\\"bad\\":%d,\\"maxOpen\\":%d}\\n", bad, max_open); return 0;
65
+ }
66
+ if (!strcmp(scenario, "txn")) {
67
+ // a handle inside a transaction is never the one the LRU closes, and do.sql.close refuses it
68
+ path_of(p, "do", 0); int a = sb_db_for(p); sql(a, "CREATE TABLE t (v)"); sql(a, "BEGIN"); sql(a, "INSERT INTO t VALUES (1)");
69
+ for (int i = 1; i <= sb_do_sql_open + 3; i++) { path_of(p, "do", i); int idx = sb_db_for(p); if (idx < 0) { printf("{\\"error\\":\\"open %d\\"}\\n", i); return 1; } sql(idx, "CREATE TABLE IF NOT EXISTS t (v)"); }
70
+ path_of(p, "do", 0); int refused = sb_db_close_path(p); int still = sb_db_for(p) == a;
71
+ sql(a, "COMMIT"); int closed = sb_db_close_path(p);
72
+ printf("{\\"refused\\":%d,\\"still\\":%d,\\"closed\\":%d}\\n", refused, still, closed); return 0;
73
+ }
74
+ if (!strcmp(scenario, "nowal")) {
75
+ path_of(p, "do", 5); int idx = sb_db_for(p); sql(idx, "CREATE TABLE t (v)"); for (int i = 0; i < 100; i++) sql(idx, "INSERT INTO t VALUES (1)");
76
+ char wal[700]; snprintf(wal, sizeof wal, "%s-wal", p); struct stat st; int walBefore = !stat(wal, &st);
77
+ int closed = sb_db_close_path(p); int walAfter = !stat(wal, &st); snprintf(wal, sizeof wal, "%s-shm", p); int shmAfter = !stat(wal, &st);
78
+ int again = sb_db_close_path(p);
79
+ printf("{\\"walBefore\\":%d,\\"closed\\":%d,\\"walAfter\\":%d,\\"shmAfter\\":%d,\\"again\\":%d}\\n", walBefore, closed, walAfter, shmAfter, again); return 0;
80
+ }
81
+ if (!strcmp(scenario, "share")) {
82
+ // opens N D1-style databases (not limited by the object slots) and reports each size
83
+ int n = atoi(argv[3]); long sum = 0, mx = 0, mn = 1 << 30;
84
+ for (int i = 0; i < n; i++) { path_of(p, "d1", i); int idx = sb_db_for(p); if (idx < 0) { printf("{\\"error\\":\\"open %d\\"}\\n", i); return 1; } sql(idx, "CREATE TABLE IF NOT EXISTS t (v)"); }
85
+ for (int i = 0; i < sb_db_count; i++) if (sb_dbs[i]) { sum += sb_cache_kb[i]; if (sb_cache_kb[i] > mx) mx = sb_cache_kb[i]; if (sb_cache_kb[i] < mn) mn = sb_cache_kb[i]; }
86
+ printf("{\\"n\\":%d,\\"sumKb\\":%ld,\\"maxKb\\":%ld,\\"minKb\\":%ld,\\"shareKb\\":%d}\\n", n, sum, mx, mn, sb_cache_share_kb()); return 0;
87
+ }
88
+ if (!strcmp(scenario, "growshrink")) {
89
+ path_of(p, "d1", 0); int idx = sb_db_for(p); sql(idx, "CREATE TABLE t (v TEXT)");
90
+ sql(idx, "BEGIN"); for (int i = 0; i < 12000; i++) sql(idx, "INSERT INTO t VALUES (hex(randomblob(100)))"); sql(idx, "COMMIT");
91
+ int start = sb_cache_kb[idx];
92
+ for (int i = 0; i < 400; i++) { idx = sb_db_for(p); scalar(idx, "SELECT sum(length(v)) FROM t"); }
93
+ int grown = sb_cache_kb[idx];
94
+ for (int i = 1; i < 32; i++) { char q[600]; path_of(q, "d1", i); int j = sb_db_for(q); sql(j, "CREATE TABLE IF NOT EXISTS t (v)"); }
95
+ long sum = 0; for (int i = 0; i < sb_db_count; i++) if (sb_dbs[i]) sum += sb_cache_kb[i];
96
+ printf("{\\"start\\":%d,\\"grown\\":%d,\\"after\\":%d,\\"sumKb\\":%ld,\\"targetKb\\":%ld,\\"grow\\":%lu,\\"shrink\\":%lu}\\n", start, grown, sb_cache_kb[0], sum, sb_cache_target_kb, sb_stat_grow, sb_stat_shrink); return 0;
97
+ }
98
+ if (!strcmp(scenario, "small")) {
99
+ // one small D1-style database, a few queries: how much SQLite memory does the worker hold
100
+ path_of(p, "d1", 0); int idx = sb_db_for(p); sql(idx, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)");
101
+ for (int i = 0; i < 40; i++) { char q[100]; snprintf(q, sizeof q, "INSERT INTO t (v) VALUES ('row %d')", i); sql(idx, q); }
102
+ for (int i = 0; i < 100; i++) { idx = sb_db_for(p); scalar(idx, "SELECT count(*) FROM t"); }
103
+ stats(); return 0;
104
+ }
105
+ if (!strcmp(scenario, "bench")) {
106
+ // E7 (plan 016's G13): 200 calls of a 20-row feed after a cold open, then three scans of 50 MB
107
+ path_of(p, "d1", 0); int idx = sb_db_for(p); sql(idx, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)");
108
+ sql(idx, "BEGIN"); for (int i = 0; i < 250000; i++) sql(idx, "INSERT INTO t (v) VALUES (hex(randomblob(100)))"); sql(idx, "COMMIT");
109
+ sb_db_close_path(p);
110
+ struct timespec a; double scan[3];
111
+ idx = sb_db_for(p); clock_gettime(CLOCK_MONOTONIC, &a);
112
+ for (int i = 0; i < 200; i++) { idx = sb_db_for(p); sqlite3_stmt* st; sqlite3_prepare_v2(sb_dbs[idx], "SELECT id, v FROM t ORDER BY id DESC LIMIT 20", -1, &st, 0); while (sqlite3_step(st) == 100) {} sqlite3_finalize(st); }
113
+ double feed = ms_since(&a) * 1000 / 200;
114
+ for (int r = 0; r < 3; r++) { idx = sb_db_for(p); clock_gettime(CLOCK_MONOTONIC, &a); scalar(idx, "SELECT sum(length(v)) FROM t"); scan[r] = ms_since(&a); }
115
+ printf("{\\"feedUs\\":%.1f,\\"scanMs\\":[%.1f,%.1f,%.1f],\\"cacheKb\\":%d}\\n", feed, scan[0], scan[1], scan[2], sb_cache_kb[idx]); return 0;
116
+ }
117
+ if (!strcmp(scenario, "busy")) {
118
+ path_of(p, "do", 9); int idx = sb_db_for(p); sql(idx, "CREATE TABLE t (v)"); sql(idx, "INSERT INTO t VALUES (1)");
119
+ sqlite3_stmt* leaked = 0; sqlite3_prepare_v2(sb_dbs[idx], "SELECT v FROM t", -1, &leaked, 0); sqlite3_step(leaked);
120
+ int first = sb_db_close_path(p); int open = sb_dbs[idx] != 0; unsigned long busy = sb_stat_busy;
121
+ sqlite3_finalize(leaked); int second = sb_db_close_path(p);
122
+ printf("{\\"first\\":%d,\\"stillOpen\\":%d,\\"busy\\":%lu,\\"second\\":%d}\\n", first, open, busy, second); return 0;
123
+ }
124
+ return 2;
125
+ }
126
+ `;
127
+ await Bun.write(join(work, "driver.c"), source);
128
+ const binary = join(work, "driver");
129
+ const amalgamation = process.env.SB_TEST_SQLITE_DIR;
130
+ const compile = Bun.spawnSync(
131
+ amalgamation
132
+ ? [
133
+ "cc",
134
+ "-O2",
135
+ `-I${amalgamation}`,
136
+ join(work, "driver.c"),
137
+ join(amalgamation, "sqlite3.o"),
138
+ "-lm",
139
+ "-ldl",
140
+ "-lpthread",
141
+ "-o",
142
+ binary,
143
+ ]
144
+ : ["cc", "-O1", join(work, "driver.c"), "-lsqlite3", "-o", binary],
145
+ );
146
+ assert.equal(compile.exitCode, 0, compile.stderr.toString());
147
+
148
+ async function run(scenario: string, env: Record<string, string> = {}, ...extra: string[]) {
149
+ const dir = await mkdtemp(join(work, "run-"));
150
+ const p = Bun.spawnSync([binary, dir, scenario, ...extra], { env: { PATH: process.env.PATH ?? "", ...env } });
151
+ assert.equal(p.exitCode, 0, `${scenario}: ${p.stdout.toString()} ${p.stderr.toString()}`);
152
+ const lines = p.stdout
153
+ .toString()
154
+ .trim()
155
+ .split("\n")
156
+ .map((l) => JSON.parse(l));
157
+ return { lines, dir, last: lines[lines.length - 1] };
158
+ }
159
+ const MB32 = String(32 << 20);
160
+ try {
161
+ // the slot count: clamp(T / 160 KiB, 8, 48) with T = limit / 8
162
+ assert.equal((await run("slots", { SB_MEMORY_LIMIT: MB32 })).last.slots, 25);
163
+ assert.equal((await run("slots", { SB_MEMORY_LIMIT: String(16 << 20) })).last.slots, 12);
164
+ assert.equal((await run("slots", { SB_MEMORY_LIMIT: String(9 << 20) })).last.slots, 8);
165
+ assert.equal((await run("slots", { SB_MEMORY_LIMIT: String(1024 << 20) })).last.slots, 48);
166
+ assert.equal((await run("slots")).last.slots, 48, "no limit: 48 slots");
167
+ assert.equal((await run("slots", { SB_MEMORY_LIMIT: MB32 })).last.targetKb, 4096);
168
+
169
+ // 50 object databases through the slots (plan 013 phase D's "50 objects through 8 slots", without capture)
170
+ const fifty = await run("fifty", { SB_MEMORY_LIMIT: MB32 });
171
+ assert.equal(fifty.last.bad, 0);
172
+ assert(fifty.last.maxOpen <= 25, `at most 25 object databases open, saw ${fifty.last.maxOpen}`);
173
+ assert.equal(fifty.lines[0].stats.busy, 0);
174
+ assert(fifty.lines[0].stats.closes >= 25);
175
+ const eight = await run("fifty", { SB_MEMORY_LIMIT: String(9 << 20) });
176
+ assert.equal(eight.last.bad, 0);
177
+ assert(eight.last.maxOpen <= 8);
178
+
179
+ // a handle inside a transaction is not closed by the LRU nor by do.sql.close
180
+ const txn = await run("txn", { SB_MEMORY_LIMIT: String(9 << 20) });
181
+ assert.deepEqual(txn.last, { refused: -1, still: 1, closed: 1 });
182
+
183
+ // a close after a write leaves one clean file: no -wal, no -shm; closing again is a no-op
184
+ // (macOS's system SQLite keeps an emptied -wal and its -shm after the last close whatever the caller does: there the
185
+ // test checks the WAL was truncated; the Linux amalgamation, which sproutd and the standalone binary use, deletes both.)
186
+ const nowal = await run("nowal", { SB_MEMORY_LIMIT: MB32 });
187
+ assert.equal(nowal.last.walBefore, 1);
188
+ assert.equal(nowal.last.closed, 1);
189
+ assert.equal(nowal.last.again, 0);
190
+ const files = await readdir(join(nowal.dir, "do-sql/C"));
191
+ if (process.platform === "darwin") {
192
+ for (const f of files.filter((f) => f.endsWith("-wal")))
193
+ assert.equal((await stat(join(nowal.dir, "do-sql/C", f))).size, 0, "the WAL was checkpointed and truncated");
194
+ } else {
195
+ assert.deepEqual(nowal.last, { walBefore: 1, closed: 1, walAfter: 0, shmAfter: 0, again: 0 });
196
+ assert.equal(files.length, 1, files.join(","));
197
+ }
198
+
199
+ // the share rule: 256 KiB each up to 16 open, shrinking toward T / n above that; the sum stays under T
200
+ for (const n of [1, 4, 16, 32]) {
201
+ const r = (await run("share", { SB_MEMORY_LIMIT: MB32 }, String(n))).last;
202
+ assert(r.sumKb <= 4096, `n=${n}: ${JSON.stringify(r)}`);
203
+ assert(r.maxKb <= 256, `n=${n}: starts at 256 KiB, saw ${r.maxKb}`);
204
+ if (n <= 16) assert.equal(r.minKb, 256, `n=${n}`);
205
+ else assert(r.maxKb <= Math.max(128, r.shareKb) + 128, `n=${n}: ${JSON.stringify(r)}`);
206
+ }
207
+ const unlimited = (await run("share", {}, "32")).last;
208
+ assert.equal(unlimited.minKb, 256, "no limit: 256 KiB each, no shrinking");
209
+ // SB_SQLITE_CACHE_MB keeps its old meaning: a fixed size
210
+ const fixed = (await run("share", { SB_SQLITE_CACHE_MB: "3", SB_MEMORY_LIMIT: MB32 }, "20")).last;
211
+ assert.equal(fixed.minKb, 3072);
212
+ assert.equal(fixed.maxKb, 3072);
213
+
214
+ // growth on evidence, then shrink when more databases open
215
+ const gs = (await run("growshrink", { SB_MEMORY_LIMIT: MB32 })).last;
216
+ assert.equal(gs.start, 256);
217
+ assert(gs.grown > 256 && gs.grown <= 2048, JSON.stringify(gs));
218
+ assert(gs.grow > 0 && gs.shrink > 0, JSON.stringify(gs));
219
+ assert(gs.after <= 256 && gs.sumKb <= gs.targetKb, JSON.stringify(gs));
220
+
221
+ // a leaked statement: sqlite3_close says BUSY, the slot stays open and is counted, never close_v2
222
+ const busy = (await run("busy", { SB_MEMORY_LIMIT: MB32 })).last;
223
+ assert.deepEqual(busy, { first: -1, stillOpen: 1, busy: 1, second: 1 });
224
+
225
+ // one small D1 database: well under 300 KiB of SQLite memory (E7)
226
+ const small = (await run("small", { SB_MEMORY_LIMIT: MB32 })).last;
227
+ assert(small.stats.memUsed < 300 * 1024, JSON.stringify(small));
228
+
229
+ if (process.env.SB_DB_TABLE_BENCH) {
230
+ // E7 timing (not an assertion: machines differ): the adaptive cache against a fixed 2 MiB, interleaved
231
+ for (const [name, env] of [
232
+ ["fixed 2 MiB", { SB_SQLITE_CACHE_MB: "2" }],
233
+ ["adaptive", { SB_MEMORY_LIMIT: MB32 }],
234
+ ["fixed 2 MiB", { SB_SQLITE_CACHE_MB: "2" }],
235
+ ["adaptive", { SB_MEMORY_LIMIT: MB32 }],
236
+ ["fixed 2 MiB", { SB_SQLITE_CACHE_MB: "2" }],
237
+ ["adaptive", { SB_MEMORY_LIMIT: MB32 }],
238
+ ] as const)
239
+ console.log(name.padEnd(12), JSON.stringify((await run("bench", env)).last));
240
+ }
241
+ console.log("native-db-table: ok");
242
+ } finally {
243
+ await rm(work, { recursive: true, force: true });
244
+ }
@@ -0,0 +1,345 @@
1
+ import assert from "node:assert/strict";
2
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Durable Object eviction (plan 018, compatibility date 2026-11-21) on a standalone binary: an idle object leaves memory after
7
+ // SB_DO_IDLE_MS and its next event constructs it again; the cap, the overloaded error, alarms into evicted objects, pins, the
8
+ // reset on a failed blockConcurrencyWhile and the revoked zombie. These are the first copy of the conformance cases of
9
+ // sproutd's conformance/bindings/do.js (plan 018 phase 4). Needs SB_CLI, a sproutboat-cli checkout linked to this one.
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 sprout)");
13
+ process.exit(0);
14
+ }
15
+ const IDLE = 300; // ms; every case waits four times that
16
+ const WAIT = IDLE * 4;
17
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ function freePort(): number {
20
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
21
+ const port = listener.port;
22
+ listener.stop(true);
23
+ return port;
24
+ }
25
+
26
+ const workdir = mkdtempSync(join(tmpdir(), "sb-native-do-evict-"));
27
+ const servers: ReturnType<typeof Bun.spawn>[] = [];
28
+ const fixture = new URL("./fixtures/do-evict-app.js", import.meta.url).pathname;
29
+
30
+ function build(label: string, date: string): string {
31
+ const project = join(workdir, label);
32
+ mkdirSync(join(project, "src"), { recursive: true });
33
+ copyFileSync(fixture, join(project, "src/index.js"));
34
+ writeFileSync(
35
+ join(project, "sproutboat.jsonc"),
36
+ JSON.stringify({
37
+ name: "evict-app",
38
+ main: "src/index.js",
39
+ compatibility_date: date,
40
+ durable_objects: { OBJ: "Obj", SLOW: "Slow", FLAKY: "Flaky" },
41
+ }),
42
+ );
43
+ const built = Bun.spawnSync(["bun", join(cli!, "src/main.ts"), "build", "--standalone", "--target", "host"], {
44
+ cwd: project,
45
+ stdout: "ignore",
46
+ stderr: "inherit",
47
+ });
48
+ assert.equal(built.exitCode, 0, `${label}: the CLI could not build the sprout`);
49
+ return join(project, "dist/evict-app");
50
+ }
51
+
52
+ interface Server {
53
+ port: number;
54
+ kill(): Promise<void>;
55
+ get(path: string): Promise<any>;
56
+ raw(path: string): Promise<Response>;
57
+ stderr(): string;
58
+ }
59
+ async function start(binary: string, label: string, env: Record<string, string>, capture = false): Promise<Server> {
60
+ const port = freePort();
61
+ const data = join(workdir, `data-${label}`);
62
+ const proc = Bun.spawn([binary], {
63
+ cwd: workdir,
64
+ env: { ...process.env, SB_DATA_DIR: data, PORT: String(port), ...env },
65
+ stdout: "ignore",
66
+ stderr: capture ? "pipe" : "inherit",
67
+ });
68
+ servers.push(proc);
69
+ let captured = "";
70
+ if (capture && proc.stderr instanceof ReadableStream) {
71
+ const stream = proc.stderr;
72
+ void (async () => {
73
+ for await (const chunk of stream) captured += new TextDecoder().decode(chunk);
74
+ })();
75
+ }
76
+ let up = false;
77
+ for (let attempt = 0; attempt < 100 && !up; attempt++) {
78
+ try {
79
+ await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) });
80
+ up = true;
81
+ } catch {
82
+ if (proc.exitCode !== null) break;
83
+ await sleep(100);
84
+ }
85
+ }
86
+ assert(up, `${label}: the compiled handler did not start`);
87
+ return {
88
+ port,
89
+ stderr: () => captured,
90
+ raw: (path) => fetch(`http://127.0.0.1:${port}${path}`),
91
+ get: async (path) => (await fetch(`http://127.0.0.1:${port}${path}`)).json(),
92
+ kill: async () => {
93
+ proc.kill(9);
94
+ await proc.exited;
95
+ },
96
+ };
97
+ }
98
+ let ids = 0;
99
+ const fresh = () => `o${Date.now().toString(36)}${ids++}`;
100
+
101
+ try {
102
+ const [binary, before] = await Promise.all([
103
+ Promise.resolve(build("on", "2026-11-21")),
104
+ Promise.resolve(build("off", "2026-11-20")),
105
+ ]);
106
+ const main = await start(binary, "main", { SB_DO_IDLE_MS: String(IDLE) });
107
+
108
+ // evictMem: an in-memory counter is 1 again after the idle wait; without the wait it is 2
109
+ {
110
+ const id = fresh();
111
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 1);
112
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 2, "no wait: the object stayed");
113
+ await sleep(WAIT);
114
+ const after = await main.get(`/via/${id}/mem`);
115
+ assert.equal(after.mem, 1, "after the idle wait the instance is new");
116
+ // evictCtor: the constructor count in storage went up by one per wake, not per request
117
+ assert.equal(after.ctor, 2);
118
+ for (let i = 0; i < 5; i++) assert.equal((await main.get(`/via/${id}/mem`)).ctor, 2);
119
+ }
120
+
121
+ // evictStorage, evictSql: a key and a row written before the wait are read after it
122
+ {
123
+ const id = fresh();
124
+ await main.get(`/via/${id}/put?k=key&v=value1`);
125
+ await sleep(WAIT);
126
+ const got = await main.get(`/via/${id}/get?k=key`);
127
+ assert.equal(got.k, "value1");
128
+ assert.deepEqual(got.rows, ["value1"]);
129
+ assert.equal(got.mem, 0, "a new instance");
130
+ }
131
+
132
+ // evictBusy: a request that sits in the object for several idle waits is not evicted in flight
133
+ {
134
+ const id = fresh();
135
+ await main.get(`/via/${id}/mem`);
136
+ const held = await main.get(`/via/${id}/hold?ms=${WAIT * 2}`);
137
+ assert.equal(held.ctor, 1);
138
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 2, "same instance after the long request");
139
+ }
140
+
141
+ // evictWaitUntil: a waitUntil task that outlives the response keeps the object, and its write is there afterwards
142
+ {
143
+ const id = fresh();
144
+ await main.get(`/via/${id}/mem`);
145
+ await main.get(`/via/${id}/waituntil?ms=${WAIT}`);
146
+ await sleep(IDLE * 2);
147
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 2, "kept while the task runs");
148
+ await sleep(WAIT * 2);
149
+ const got = await main.get(`/via/${id}/get?k=wu`);
150
+ assert.equal(got.k, "done");
151
+ assert.equal(got.mem, 0, "and then it left");
152
+ }
153
+
154
+ // evictTimerPin: a pending setTimeout keeps the object; it leaves after the timer fired and another wait
155
+ {
156
+ const id = fresh();
157
+ await main.get(`/via/${id}/mem`);
158
+ await main.get(`/via/${id}/timer?ms=${WAIT * 2}`);
159
+ await sleep(WAIT);
160
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 2, "pinned by the timer");
161
+ await sleep(WAIT * 2 + IDLE);
162
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 1, "gone after the timer fired and another wait");
163
+ }
164
+
165
+ // a never-cleared setInterval pins forever
166
+ {
167
+ const id = fresh();
168
+ await main.get(`/via/${id}/interval`);
169
+ await sleep(WAIT * 2);
170
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 1, "not evicted: the interval lives");
171
+ await sleep(WAIT);
172
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 2);
173
+ }
174
+
175
+ // evictTxn: a transaction that awaits across the wait commits; the object was not evicted under it
176
+ {
177
+ const id = fresh();
178
+ await main.get(`/via/${id}/mem`);
179
+ await main.get(`/via/${id}/txn?ms=${WAIT * 2}`);
180
+ assert.equal((await main.get(`/via/${id}/mem`)).mem, 2);
181
+ assert.equal((await main.get(`/via/${id}/get?k=t`)).k, 1, "committed");
182
+ }
183
+
184
+ // evictZombie: a promise the handler left running writes after the object was evicted: rejected, key absent
185
+ {
186
+ const id = fresh();
187
+ await main.get(`/via/${id}/zombie?n=1`);
188
+ await sleep(WAIT);
189
+ await main.get("/release/1");
190
+ const result = (await main.get("/zombie-result/1")).result;
191
+ assert.match(result, /^rejected: .*evicted/, result);
192
+ assert.equal((await main.get(`/via/${id}/get?k=z`)).k, null, "the zombie's key is absent");
193
+ }
194
+
195
+ // evictAbort: abort() then a call: new instance, storage kept, SQL readable
196
+ {
197
+ const id = fresh();
198
+ await main.get(`/via/${id}/put?k=a&v=1`);
199
+ await main.get(`/via/${id}/mem`);
200
+ const aborted = await main.raw(`/via/${id}/abort`);
201
+ assert.equal(aborted.status, 503);
202
+ const next = await main.get(`/via/${id}/get?k=a`);
203
+ assert.equal(next.mem, 0);
204
+ assert.equal(next.k, "1");
205
+ assert.deepEqual(next.rows, ["1"]);
206
+ assert(next.ctor >= 2, "constructed again");
207
+ }
208
+
209
+ // evictBcw: the first event after a wake sees the field the constructor's blockConcurrencyWhile set
210
+ {
211
+ const id = fresh();
212
+ await main.get(`/via/${id}/mem`);
213
+ await sleep(WAIT);
214
+ assert.equal((await main.get(`/via/${id}/mem`)).ctor, 2);
215
+ }
216
+ // evictBcwThrow: a constructor whose blockConcurrencyWhile rejects once: that call fails, the next constructs again and succeeds
217
+ {
218
+ const id = fresh();
219
+ const first = await main.raw(`/via/${id}/x?cls=Flaky`);
220
+ assert.equal(first.status, 503, "the first call fails");
221
+ const second = await main.get(`/via/${id}/x?cls=Flaky`);
222
+ assert.equal(second.ok, 2, "built again and succeeded");
223
+ }
224
+
225
+ // evictAlarm: an alarm set before the wait fires after it; the constructor ran before alarm(); one run
226
+ {
227
+ const id = fresh();
228
+ await main.get(`/via/${id}/alarm?ms=${WAIT * 2}`);
229
+ await sleep(WAIT);
230
+ let log = await main.get(`/via/${id}/alarmlog`);
231
+ assert.equal(log.runs, 0, "the alarm is not due yet");
232
+ const ctorBefore = log.ctor;
233
+ assert.equal(ctorBefore, 2, "the read above woke the evicted object");
234
+ await sleep(WAIT * 3);
235
+ log = await main.get(`/via/${id}/alarmlog`);
236
+ assert.equal(log.runs, 1);
237
+ assert.equal(
238
+ log.log[0].ctor,
239
+ ctorBefore + 1,
240
+ "the object was evicted again and its constructor ran before alarm()",
241
+ );
242
+ assert.equal(log.log[0].retryCount, 0);
243
+ }
244
+
245
+ // evictAlarmRetry: an alarm that throws twice, with a wait between, sees retryCount 0, 1, 2 (the row's count, not the instance's)
246
+ {
247
+ const id = fresh();
248
+ await main.get(`/via/${id}/failalarm?n=2&ms=100`);
249
+ let log = await main.get(`/via/${id}/alarmlog`);
250
+ for (let i = 0; i < 40 && log.runs < 3; i++) {
251
+ await sleep(500);
252
+ log = await main.get(`/via/${id}/alarmlog`);
253
+ }
254
+ assert.deepEqual(
255
+ log.log.map((r) => r.retryCount),
256
+ [0, 1, 2],
257
+ );
258
+ assert.equal(log.runs, 3);
259
+ }
260
+
261
+ // evictManyAlarms: 40 objects with alarms due at once all run exactly once
262
+ {
263
+ const base = fresh();
264
+ const names = Array.from({ length: 40 }, (_, i) => `${base}-${i}`);
265
+ await Promise.all(names.map((n) => main.get(`/via/${n}/alarm?ms=1500`)));
266
+ await sleep(6000);
267
+ const runs = await Promise.all(names.map(async (n) => (await main.get(`/via/${n}/alarmlog`)).runs));
268
+ assert.deepEqual(
269
+ runs,
270
+ names.map(() => 1),
271
+ );
272
+ }
273
+ await main.kill();
274
+
275
+ // capWait, capOverloaded: at most 4 objects awake; 8 short requests all answer; 4 long ones and a fifth: the fifth is overloaded
276
+ {
277
+ const capped = await start(binary, "cap", { SB_DO_IDLE_MS: "600000", SB_DO_MAX: "4", SB_DO_WAIT_MS: "800" });
278
+ const names = Array.from({ length: 8 }, () => fresh());
279
+ const answers = await Promise.all(names.map((n) => capped.get(`/via/${n}/hold?ms=150`)));
280
+ assert.equal(answers.length, 8);
281
+ for (const a of answers) assert.equal(a.error, undefined, JSON.stringify(a));
282
+ const longs = Array.from({ length: 4 }, () => fresh());
283
+ const holding = longs.map((n) => capped.get(`/via/${n}/hold?ms=2500`));
284
+ await sleep(300);
285
+ const t0 = performance.now();
286
+ const fifth = await capped.get(`/via/${fresh()}/mem`);
287
+ const waited = performance.now() - t0;
288
+ assert.equal(fifth.overloaded, true, JSON.stringify(fifth));
289
+ assert(waited >= 700, `waited ${Math.round(waited)} ms before the error`);
290
+ for (const h of await Promise.all(holding))
291
+ assert.equal(h.error, undefined, "the long requests were not disturbed");
292
+ // the slots are free again
293
+ assert.equal((await capped.get(`/via/${fresh()}/mem`)).mem, 1);
294
+ await capped.kill();
295
+ }
296
+
297
+ // the per-object queue limit: events queued behind one object's gate beyond SB_DO_QUEUE_MAX are overloaded
298
+ {
299
+ const queued = await start(binary, "queue", { SB_DO_IDLE_MS: "600000", SB_DO_QUEUE_MAX: "3" });
300
+ const id = fresh();
301
+ const calls = await Promise.all(Array.from({ length: 6 }, () => queued.get(`/via/${id}/x?cls=Slow`)));
302
+ const overloaded = calls.filter((c) => c.overloaded === true).length;
303
+ const served = calls.filter((c) => c.ready === true).length;
304
+ assert.equal(served, 3, JSON.stringify(calls));
305
+ assert.equal(overloaded, 3, JSON.stringify(calls));
306
+ await queued.kill();
307
+ }
308
+
309
+ // the sweep runs on its own timer: with no further request an idle object still leaves (the counters say so on stderr)
310
+ {
311
+ const quiet = await start(binary, "sweep", { SB_DO_IDLE_MS: String(IDLE), SB_DO_STATS: "1" }, true);
312
+ await quiet.get(`/via/${fresh()}/mem`);
313
+ await sleep(WAIT);
314
+ const text = quiet.stderr();
315
+ assert.match(text, /objects_awake=1 /, text);
316
+ assert.match(text, /objects_awake=0 .*evicted_idle=1 /, text);
317
+ await quiet.kill();
318
+ }
319
+
320
+ // SB_DO_IDLE_MS=0 keeps everything resident (the cap still applies)
321
+ {
322
+ const resident = await start(binary, "resident", { SB_DO_IDLE_MS: "0" });
323
+ const id = fresh();
324
+ await resident.get(`/via/${id}/mem`);
325
+ await sleep(WAIT);
326
+ assert.equal((await resident.get(`/via/${id}/mem`)).mem, 2);
327
+ await resident.kill();
328
+ }
329
+
330
+ // before the compatibility date nothing evicts
331
+ {
332
+ const old = await start(before, "before", { SB_DO_IDLE_MS: String(IDLE) });
333
+ const id = fresh();
334
+ await old.get(`/via/${id}/mem`);
335
+ await sleep(WAIT);
336
+ const after = await old.get(`/via/${id}/mem`);
337
+ assert.equal(after.mem, 2, "the object stayed resident");
338
+ assert.equal(after.ctor, 1);
339
+ await old.kill();
340
+ }
341
+ console.log("native-do-evict: ok");
342
+ } finally {
343
+ for (const server of servers) server.kill(9);
344
+ rmSync(workdir, { recursive: true, force: true });
345
+ }
@@ -0,0 +1,79 @@
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 } from "node:path";
5
+ import { compileNative } from "./native-compile";
6
+ import { standaloneChannel } from "../../toolchain/src/a21/standalone-channel";
7
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
8
+
9
+ // A standalone server built the way the CLI's host and release builds are: `-flto -O3`. Every other native test
10
+ // compiles without LTO, and that hid a bug on Porffor alpha 23 for macOS arm64: the bridge called the generated
11
+ // main (`p__main__23main`) as a plain C function while the definition is swiftcall, which is harmless across
12
+ // object files and, once LTO sees both, becomes `unreachable`. The binary died with SIGTRAP before it listened.
13
+ // The server must start and answer through every way the host C enters generated code: startup, a request,
14
+ // a timer, a settled promise.
15
+ const handler = `export default {async fetch(request) {
16
+ const url = new URL(request.url);
17
+ if (url.pathname === "/timer") { await new Promise((done) => setTimeout(done, 20)); return new Response("timer ok"); }
18
+ if (url.pathname === "/echo") return new Response("echo:" + (await request.text()));
19
+ if (url.pathname === "/json") return Response.json({ n: [1, 2, 3], s: "é" });
20
+ return new Response("hello");
21
+ }};`;
22
+
23
+ if (standaloneChannel() !== "alpha-23") {
24
+ // the bug is alpha 23's (PORF_CC); alpha 22 has no swiftcall and alpha 19 builds through the compiler's own native build
25
+ console.log(`SKIPPED: the -flto check is for the alpha-23 channel, not ${standaloneChannel()}`);
26
+ process.exit(0);
27
+ }
28
+
29
+ function freePort(): number {
30
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
31
+ const port = listener.port;
32
+ listener.stop(true);
33
+ return port;
34
+ }
35
+
36
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-lto-"));
37
+ let server: ReturnType<typeof Bun.spawn> | undefined;
38
+ try {
39
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
40
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
41
+ const prelude = core.replace(TRANSPORT_MARKER, broker);
42
+ const generated = join(workdir, "lto.generated.js");
43
+ const binary = join(workdir, "lto-native");
44
+ await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
45
+
46
+ await compileNative({ generated, binary, dir: workdir, flto: true, optimize: 3 });
47
+
48
+ const port = freePort();
49
+ server = Bun.spawn([binary], {
50
+ cwd: workdir,
51
+ env: { ...process.env, PORT: String(port) },
52
+ stdout: "ignore",
53
+ stderr: "inherit",
54
+ });
55
+ let ready = false;
56
+ for (let attempt = 0; attempt < 50 && !ready; attempt++) {
57
+ try {
58
+ await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
59
+ ready = true;
60
+ } catch {
61
+ if (server.exitCode !== null) break;
62
+ await Bun.sleep(100);
63
+ }
64
+ }
65
+ assert(ready, `the -flto -O3 binary did not start (exit ${server.exitCode}, signal ${server.signalCode})`);
66
+ const base = `http://127.0.0.1:${port}`;
67
+ assert.equal(await (await fetch(`${base}/`)).text(), "hello");
68
+ assert.equal(await (await fetch(`${base}/timer`)).text(), "timer ok");
69
+ assert.equal(await (await fetch(`${base}/echo`, { method: "POST", body: "abc" })).text(), "echo:abc");
70
+ assert.deepEqual(await (await fetch(`${base}/json`)).json(), { n: [1, 2, 3], s: "é" });
71
+ assert.equal(server.exitCode, null, "the server stayed up");
72
+ console.log("A -flto -O3 standalone build starts and answers: startup, request, timer, promise, body");
73
+ } finally {
74
+ if (server) {
75
+ server.kill(9);
76
+ await server.exited;
77
+ }
78
+ await rm(workdir, { recursive: true, force: true });
79
+ }