@sproutboat/runtime 0.24.0 → 0.26.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 +51 -0
- package/LTX_REPLICATION.md +19 -4
- package/package.json +1 -1
- package/src/kv-shim.test.js +124 -0
- package/src/native-fetch-prelude.js +121 -1
- package/src/transport-embedded.js +279 -14
- package/test/ltx-restore.ts +110 -0
- package/test/native-d1.ts +248 -5
- package/test/native-ltx.ts +60 -2
package/test/native-d1.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { restore } from "./ltx-restore";
|
|
2
4
|
import { closeSync, openSync, readFileSync, readdirSync, readSync, writeSync } from "node:fs";
|
|
3
5
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
6
|
import { tmpdir } from "node:os";
|
|
@@ -47,6 +49,8 @@ int main(void) {
|
|
|
47
49
|
if (!strncmp(line, ":break ", 7)) { line[strcspn(line, "\\t")] = 0; char p[1024]; snprintf(p, sizeof(p), "%s.ltx/position.tmp", line + 7); mkdir(p, 0700); puts("{}"); fflush(stdout); continue; }
|
|
48
50
|
char* sql = strchr(line, '\\t'); if (!sql) return 2; *sql++ = 0;
|
|
49
51
|
char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
|
|
52
|
+
if (!strncmp(line, ":db ", 4)) { int r = sql[0] == 'p' ? sb_db_page_size(line + 4) : sb_db_quota_freeze(line + 4, sql[0] == 'c' ? -1 : sql[0] == 'f'); printf("{\\"n\\":%d}\\n", r); fflush(stdout); continue; }
|
|
53
|
+
if (!strncmp(line, ":bytes ", 7)) { line[strcspn(line, "\\t")] = 0; printf("{\\"n\\":%lld}\\n", sb_dir_bytes(line + 7, 0)); fflush(stdout); continue; }
|
|
50
54
|
// ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
|
|
51
55
|
char* out = !strncmp(line, ":script ", 8) ? sb_sql_script(line + 8, sql) : sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
|
|
52
56
|
}
|
|
@@ -147,7 +151,9 @@ int main(void) {
|
|
|
147
151
|
const toDriver = openSync(fifoIn, "w"),
|
|
148
152
|
fromDriver = openSync(fifoOut, "r");
|
|
149
153
|
let pending = "";
|
|
154
|
+
let driverClosed = false;
|
|
150
155
|
const sqlRaw = (path: string, sql: string, params: string) => {
|
|
156
|
+
if (driverClosed) throw new Error("original D1 driver is closed");
|
|
151
157
|
writeSync(toDriver, `${path}\t${sql}\t${params}\n`);
|
|
152
158
|
const chunk = Buffer.alloc(65536);
|
|
153
159
|
while (!pending.includes("\n")) {
|
|
@@ -172,6 +178,89 @@ int main(void) {
|
|
|
172
178
|
"__sbSqlScriptRaw",
|
|
173
179
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
|
|
174
180
|
)(sqlRaw, sql, scriptRaw);
|
|
181
|
+
// The storage quota, lifted from "The storage quota" to __sbStore, for DIR and LIMIT.
|
|
182
|
+
const quotaStart = transport.indexOf("// The storage quota: an app's data directory");
|
|
183
|
+
const quotaEnd = transport.indexOf("function __sbStore() {");
|
|
184
|
+
assert(quotaStart > 0 && quotaEnd > quotaStart, "quota block moved");
|
|
185
|
+
const liftQuota = (dir: string, limit: number) =>
|
|
186
|
+
new Function(
|
|
187
|
+
"__sbEnv",
|
|
188
|
+
"__sbDir",
|
|
189
|
+
"__sbDirBytesRaw",
|
|
190
|
+
"__sbD1DbRaw",
|
|
191
|
+
"__sbSql",
|
|
192
|
+
"__sbSqlRaw",
|
|
193
|
+
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull, expire() { __sbQuotaAt = -1; } };`,
|
|
194
|
+
)(
|
|
195
|
+
(name: string) => (name === "SB_STORAGE_MAX_BYTES" ? String(limit) : ""),
|
|
196
|
+
() => dir,
|
|
197
|
+
(d: string) => String(JSON.parse(sqlRaw(":bytes " + d, "", "")).n),
|
|
198
|
+
(path: string, what: string) => String(JSON.parse(sqlRaw(":db " + path, what, "")).n),
|
|
199
|
+
sql,
|
|
200
|
+
sqlRaw,
|
|
201
|
+
);
|
|
202
|
+
const dirBytes = (d: string) => Number(JSON.parse(sqlRaw(":bytes " + d, "", "")).n);
|
|
203
|
+
// Another driver process (a restarted worker), with its own FIFOs and lifted blocks.
|
|
204
|
+
const connect = (tag: string) => {
|
|
205
|
+
const inPath = join(work, "in-" + tag),
|
|
206
|
+
outPath = join(work, "out-" + tag);
|
|
207
|
+
assert.equal(Bun.spawnSync(["mkfifo", inPath, outPath]).exitCode, 0);
|
|
208
|
+
const child = Bun.spawn(["sh", "-c", `exec "${binary}" < "${inPath}" > "${outPath}"`], {
|
|
209
|
+
env: { ...process.env, SB_LTX_ENABLE: "1", SB_LTX_DATABASE: "LTX" },
|
|
210
|
+
});
|
|
211
|
+
const to = openSync(inPath, "w"),
|
|
212
|
+
from = openSync(outPath, "r");
|
|
213
|
+
let buffered = "";
|
|
214
|
+
const raw = (path: string, text: string, params: string) => {
|
|
215
|
+
writeSync(to, `${path}\t${text}\t${params}\n`);
|
|
216
|
+
const chunk = Buffer.alloc(65536);
|
|
217
|
+
while (!buffered.includes("\n")) {
|
|
218
|
+
const n = readSync(from, chunk, 0, chunk.length, null);
|
|
219
|
+
if (n <= 0) throw new Error("driver exited");
|
|
220
|
+
buffered += chunk.toString("utf8", 0, n);
|
|
221
|
+
}
|
|
222
|
+
const line = buffered.slice(0, buffered.indexOf("\n"));
|
|
223
|
+
buffered = buffered.slice(line.length + 1);
|
|
224
|
+
return line;
|
|
225
|
+
};
|
|
226
|
+
const run = (path: string, text: string, params: unknown[]) => {
|
|
227
|
+
const r = JSON.parse(raw(path, text, JSON.stringify(params)));
|
|
228
|
+
if (r.ok === false) throw new Error("sqlite: " + r.error);
|
|
229
|
+
return r;
|
|
230
|
+
};
|
|
231
|
+
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
232
|
+
const lifted = new Function(
|
|
233
|
+
"__sbSqlRaw",
|
|
234
|
+
"__sbSql",
|
|
235
|
+
"__sbSqlScriptRaw",
|
|
236
|
+
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
|
|
237
|
+
)(raw, run, (path: string, text: string) => raw(":script " + path, text, "[]"));
|
|
238
|
+
const lift = (dir: string, limit: number) =>
|
|
239
|
+
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
240
|
+
new Function(
|
|
241
|
+
"__sbEnv",
|
|
242
|
+
"__sbDir",
|
|
243
|
+
"__sbDirBytesRaw",
|
|
244
|
+
"__sbD1DbRaw",
|
|
245
|
+
"__sbSql",
|
|
246
|
+
"__sbSqlRaw",
|
|
247
|
+
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull };`,
|
|
248
|
+
)(
|
|
249
|
+
(name: string) => (name === "SB_STORAGE_MAX_BYTES" ? String(limit) : ""),
|
|
250
|
+
() => dir,
|
|
251
|
+
(d: string) => String(JSON.parse(raw(":bytes " + d, "", "")).n),
|
|
252
|
+
(path: string, what: string) => String(JSON.parse(raw(":db " + path, what, "")).n),
|
|
253
|
+
run,
|
|
254
|
+
raw,
|
|
255
|
+
);
|
|
256
|
+
return {
|
|
257
|
+
sqlRaw: raw,
|
|
258
|
+
ops: lifted,
|
|
259
|
+
liftQuota: lift,
|
|
260
|
+
exited: child.exited,
|
|
261
|
+
close: () => (closeSync(to), closeSync(from)),
|
|
262
|
+
};
|
|
263
|
+
};
|
|
175
264
|
// exec() from 2026-10-24: line by line, all or nothing, the failing line named.
|
|
176
265
|
const ex = join(work, "data/d1/EXEC.sqlite");
|
|
177
266
|
ops.__sbD1ExecLines(ex, ["CREATE TABLE x (id INTEGER)", "INSERT INTO x VALUES (1); INSERT INTO x VALUES (2);"]);
|
|
@@ -257,6 +346,67 @@ int main(void) {
|
|
|
257
346
|
true,
|
|
258
347
|
);
|
|
259
348
|
assert.equal(ltxFiles(), beforeBatch + 1, "a batch is one LTX commit");
|
|
349
|
+
// With capture on, an authorization failure says so (it read "not an error" once capture
|
|
350
|
+
// had run), and the quota works: its page size comes from capture, not from the
|
|
351
|
+
// page_size pragma the capture authorizer refuses.
|
|
352
|
+
assert.throws(
|
|
353
|
+
() => ops.__sbD1Run(ltx, "PRAGMA page_size", [], true),
|
|
354
|
+
(e: Error) => e.message === "D1_ERROR: not authorized: SQLITE_AUTH",
|
|
355
|
+
);
|
|
356
|
+
assert.throws(
|
|
357
|
+
() => ops.__sbD1ExecLines(ltx, ["PRAGMA journal_mode = DELETE"]),
|
|
358
|
+
(e: Error) => e.message === "D1_EXEC_ERROR: Error in line 1: PRAGMA journal_mode = DELETE: not authorized",
|
|
359
|
+
);
|
|
360
|
+
const data = join(work, "data");
|
|
361
|
+
const roomy = liftQuota(data, dirBytes(data) + (1 << 20));
|
|
362
|
+
roomy.__sbD1Quota(ltx);
|
|
363
|
+
ops.__sbD1Run(ltx, "INSERT INTO e VALUES (10, 'under the quota')", [], true);
|
|
364
|
+
assert.equal(ops.__sbD1Run(ltx, "SELECT v FROM e WHERE id = 10", [], true).rows[0][0], "under the quota");
|
|
365
|
+
// Over the quota with capture on, every write is refused, a same-size UPDATE and a DELETE
|
|
366
|
+
// too (each would add an LTX file to the spool), and the spool stops growing; reads and
|
|
367
|
+
// read-only batches still run. Under the quota again, writes resume.
|
|
368
|
+
const tight = liftQuota(data, 1);
|
|
369
|
+
tight.__sbD1Quota(ltx);
|
|
370
|
+
const spool = ltxFiles();
|
|
371
|
+
const QUOTA =
|
|
372
|
+
"D1_ERROR: storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota: SQLITE_FULL";
|
|
373
|
+
for (const write of [
|
|
374
|
+
"INSERT INTO e VALUES (11, 'x')",
|
|
375
|
+
"UPDATE e SET v = 'y' WHERE id = 10",
|
|
376
|
+
"DELETE FROM e WHERE id = 10",
|
|
377
|
+
])
|
|
378
|
+
assert.throws(
|
|
379
|
+
() => ops.__sbD1Run(ltx, write, [], true),
|
|
380
|
+
(e: Error) => e.message === QUOTA,
|
|
381
|
+
write,
|
|
382
|
+
);
|
|
383
|
+
assert.throws(
|
|
384
|
+
() => ops.__sbD1ExecLines(ltx, ["UPDATE e SET v = 'z'"]),
|
|
385
|
+
(e: Error) => e.message.startsWith("D1_EXEC_ERROR: Error in line 1: UPDATE e SET v = 'z': storage quota exceeded"),
|
|
386
|
+
);
|
|
387
|
+
assert.throws(
|
|
388
|
+
() =>
|
|
389
|
+
ops.__sbD1Batch(
|
|
390
|
+
ltx,
|
|
391
|
+
[
|
|
392
|
+
{ sql: "SELECT 1", params: [] },
|
|
393
|
+
{ sql: "DELETE FROM e", params: [] },
|
|
394
|
+
],
|
|
395
|
+
true,
|
|
396
|
+
),
|
|
397
|
+
(e: Error) => e.message === QUOTA,
|
|
398
|
+
);
|
|
399
|
+
assert.equal(
|
|
400
|
+
ops.__sbD1Batch(ltx, [{ sql: "SELECT count(*) FROM e", params: [] }], true).length,
|
|
401
|
+
1,
|
|
402
|
+
"a read-only batch runs",
|
|
403
|
+
);
|
|
404
|
+
assert.equal(ops.__sbD1Run(ltx, "SELECT v FROM e WHERE id = 10", [], true).rows[0][0], "under the quota");
|
|
405
|
+
assert.equal(ltxFiles(), spool, "no LTX file while over the quota");
|
|
406
|
+
const again = liftQuota(data, dirBytes(data) + (1 << 20));
|
|
407
|
+
again.__sbD1Quota(ltx);
|
|
408
|
+
ops.__sbD1Run(ltx, "UPDATE e SET v = 'thawed' WHERE id = 10", [], true);
|
|
409
|
+
assert.equal(ltxFiles(), spool + 1, "writes, and capture, resume under the quota");
|
|
260
410
|
// Break capture just before the batch's RELEASE.
|
|
261
411
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
262
412
|
const breaking = new Function(
|
|
@@ -264,10 +414,14 @@ int main(void) {
|
|
|
264
414
|
"__sbSql",
|
|
265
415
|
"__sbSqlScriptRaw",
|
|
266
416
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
|
|
267
|
-
)(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
417
|
+
)(
|
|
418
|
+
(path: string, text: string, params: string) => {
|
|
419
|
+
if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
|
|
420
|
+
return sqlRaw(path, text, params);
|
|
421
|
+
},
|
|
422
|
+
sql,
|
|
423
|
+
scriptRaw,
|
|
424
|
+
);
|
|
271
425
|
const LTX_FAILED =
|
|
272
426
|
"D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR";
|
|
273
427
|
assert.throws(
|
|
@@ -286,12 +440,101 @@ int main(void) {
|
|
|
286
440
|
);
|
|
287
441
|
assert.throws(
|
|
288
442
|
() => ops.__sbD1ExecLines(ltx, ["INSERT INTO e VALUES (5, 'e')"]),
|
|
289
|
-
(e: Error) =>
|
|
443
|
+
(e: Error) =>
|
|
444
|
+
e.message ===
|
|
445
|
+
"D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR",
|
|
290
446
|
"exec() after a capture failure",
|
|
291
447
|
);
|
|
448
|
+
const qdir = join(work, "quota");
|
|
449
|
+
const qdb = join(qdir, "d1/Q.sqlite");
|
|
450
|
+
const quota = liftQuota(qdir, 262144);
|
|
451
|
+
ops.__sbD1Run(qdb, "CREATE TABLE q (b BLOB)", [], true);
|
|
452
|
+
quota.__sbD1Quota(qdb);
|
|
453
|
+
quota.__sbQuotaCheck("KV put()");
|
|
454
|
+
// Fill to the limit: a database is capped at the quota, so growth past it fails with
|
|
455
|
+
// SQLITE_FULL; then the directory is over, a KV write is refused, and reads and deletes
|
|
456
|
+
// keep working. VACUUM gives the space back and writes resume.
|
|
457
|
+
let rows = 0;
|
|
458
|
+
assert.throws(
|
|
459
|
+
() => {
|
|
460
|
+
for (; rows < 64; rows++) ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
|
|
461
|
+
},
|
|
462
|
+
(e: Error) => e.message === "D1_ERROR: database or disk is full: SQLITE_FULL",
|
|
463
|
+
);
|
|
464
|
+
assert(rows >= 2 && rows < 5, "filled to about 256 KiB: " + rows);
|
|
465
|
+
quota.expire();
|
|
466
|
+
assert.equal(quota.__sbQuotaFull(), true);
|
|
467
|
+
assert.throws(
|
|
468
|
+
() => quota.__sbQuotaCheck("KV put()"),
|
|
469
|
+
/^Error: KV put\(\) failed: storage quota exceeded \(this app stores \d+ bytes; the limit is 262144\)$/,
|
|
470
|
+
);
|
|
471
|
+
quota.__sbD1Quota(qdb);
|
|
472
|
+
assert.equal(ops.__sbD1Run(qdb, "SELECT count(*) FROM q", [], true).rows[0][0], rows, "reads still work");
|
|
473
|
+
ops.__sbD1Run(qdb, "DELETE FROM q", [], true);
|
|
474
|
+
ops.__sbD1Run(qdb, "VACUUM", [], true);
|
|
475
|
+
// No checkpoint by the app: over the quota the runtime checkpoints and truncates the WAL
|
|
476
|
+
// (once a second) before measuring again, so DELETE and VACUUM alone make room.
|
|
477
|
+
await Bun.sleep(1000);
|
|
478
|
+
quota.expire();
|
|
479
|
+
quota.__sbD1Quota(qdb);
|
|
480
|
+
quota.__sbQuotaCheck("KV put()");
|
|
481
|
+
ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
|
|
482
|
+
driverClosed = true;
|
|
292
483
|
closeSync(toDriver);
|
|
293
484
|
closeSync(fromDriver);
|
|
294
485
|
await driver.exited;
|
|
486
|
+
|
|
487
|
+
// A restart that finds the app over its quota, capture on: the first D1 call of the new
|
|
488
|
+
// worker (process) already freezes. Reads work, writes are refused, the spool and its
|
|
489
|
+
// position stay exactly as they were, and the chain restores the database byte for byte.
|
|
490
|
+
// A capture failure still reads as one, over the quota too.
|
|
491
|
+
await rm(ltx + ".ltx/position.tmp", { recursive: true, force: true });
|
|
492
|
+
const second = connect("restart");
|
|
493
|
+
const quota2 = second.liftQuota(data, 1);
|
|
494
|
+
quota2.__sbD1Quota(ltx);
|
|
495
|
+
const spoolBefore = readdirSync(generation()).sort();
|
|
496
|
+
const positionBefore = readFileSync(ltx + ".ltx/position", "utf8");
|
|
497
|
+
const count = second.ops.__sbD1Run(ltx, "SELECT count(*) FROM e", [], true).rows[0][0];
|
|
498
|
+
for (const write of ["INSERT INTO e VALUES (20, 'x')", "UPDATE e SET v = v", "DELETE FROM e"])
|
|
499
|
+
assert.throws(
|
|
500
|
+
() => second.ops.__sbD1Run(ltx, write, [], true),
|
|
501
|
+
(e: Error) => e.message === QUOTA,
|
|
502
|
+
"restart, over the quota: " + write,
|
|
503
|
+
);
|
|
504
|
+
assert.deepEqual(readdirSync(generation()).sort(), spoolBefore, "no LTX file after a clean resume over the quota");
|
|
505
|
+
assert.equal(readFileSync(ltx + ".ltx/position", "utf8"), positionBefore, "the position is unchanged");
|
|
506
|
+
second.close();
|
|
507
|
+
await second.exited;
|
|
508
|
+
{
|
|
509
|
+
const live = new Database(ltx);
|
|
510
|
+
live.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
511
|
+
assert.equal(live.query("SELECT count(*) AS n FROM e").get()?.n, count);
|
|
512
|
+
live.close();
|
|
513
|
+
const restored = join(work, "restart-restored.sqlite");
|
|
514
|
+
await restore(generation(), restored);
|
|
515
|
+
assert.deepEqual(
|
|
516
|
+
await readFile(restored),
|
|
517
|
+
await readFile(ltx),
|
|
518
|
+
"the retained chain restores the database byte for byte",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
const third = connect("failure");
|
|
522
|
+
const thirdBytes = Number(JSON.parse(third.sqlRaw(":bytes " + data, "", "")).n);
|
|
523
|
+
third.liftQuota(data, thirdBytes + (1 << 20)).__sbD1Quota(ltx);
|
|
524
|
+
const breaking3 = (path: string, text: string, params: string) => {
|
|
525
|
+
if (text.startsWith("INSERT INTO e VALUES (30")) third.sqlRaw(":break " + path, "", "");
|
|
526
|
+
return third.sqlRaw(path, text, params);
|
|
527
|
+
};
|
|
528
|
+
const brokenWrite = JSON.parse(breaking3(ltx, "INSERT INTO e VALUES (30, 'x')", "[]"));
|
|
529
|
+
assert.equal(brokenWrite.code, 10, "a capture failure reports SQLITE_IOERR");
|
|
530
|
+
const failed3 = JSON.parse(third.sqlRaw(ltx, "INSERT INTO e VALUES (31, 'y')", "[]"));
|
|
531
|
+
assert.equal(failed3.code, 10, "after a capture failure, writes report it");
|
|
532
|
+
third.liftQuota(data, 1).__sbD1Quota(ltx);
|
|
533
|
+
const failedOver = JSON.parse(third.sqlRaw(ltx, "INSERT INTO e VALUES (32, 'z')", "[]"));
|
|
534
|
+
assert.equal(failedOver.code, 10, "over the quota too, the capture failure is what a write reports");
|
|
535
|
+
assert.match(failedOver.error, /^LTX capture failed/);
|
|
536
|
+
third.close();
|
|
537
|
+
await third.exited;
|
|
295
538
|
console.log(
|
|
296
539
|
"native d1: sb_sql_run and the D1 ops pass (blob binds, atomic batch, strict counts, foreign keys, error text)",
|
|
297
540
|
);
|
package/test/native-ltx.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp, readFile, readdir, rm, writeFile, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile, stat } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
@@ -24,12 +24,18 @@ const source = `
|
|
|
24
24
|
#include <sys/stat.h>
|
|
25
25
|
#include <time.h>
|
|
26
26
|
#include <signal.h>
|
|
27
|
+
#include <errno.h>
|
|
28
|
+
// Reproduce denial of global temporary-file access on every test platform.
|
|
29
|
+
// The sproutd sandbox qualification separately exercises real Linux Landlock.
|
|
30
|
+
static FILE *forbidden_tmpfile(void) { errno=EACCES; return NULL; }
|
|
31
|
+
#define tmpfile forbidden_tmpfile
|
|
27
32
|
#define SB_MAX_DB 16
|
|
28
33
|
${transport.slice(start, end)}
|
|
29
34
|
int main(int argc,char **argv) {
|
|
30
35
|
if(argc!=2)return 2;sb_ltx_compat=1;if(sb_ltx_register())return 3;
|
|
31
36
|
char lockpath[1024];snprintf(lockpath,sizeof(lockpath),"%s.ltx.lock",argv[1]);
|
|
32
37
|
int lock=open(lockpath,O_RDWR|O_CREAT,0600);if(lock<0||flock(lock,LOCK_EX|LOCK_NB))return 4;
|
|
38
|
+
if(getenv("SB_LTX_TEST_HOLD_LOCK")){puts("locked");fflush(stdout);getchar();close(lock);return 0;}
|
|
33
39
|
sqlite3 *db=0;if(sqlite3_open_v2(argv[1],&db,6,"sb-ltx-bounded"))return 5;
|
|
34
40
|
if(getenv("SB_LTX_TEST_AUTO_VACUUM"))sqlite3_exec(db,"PRAGMA auto_vacuum=INCREMENTAL;",0,0,0);
|
|
35
41
|
if(sqlite3_exec(db,"PRAGMA journal_mode=WAL;PRAGMA cache_size=-2000;",0,0,0)||sb_ltx_start(0,db,argv[1],lock))return 6;
|
|
@@ -153,6 +159,13 @@ async function restore(epoch: string, output: string) {
|
|
|
153
159
|
await writeFile(output, db);
|
|
154
160
|
return { files: files.length, txid };
|
|
155
161
|
}
|
|
162
|
+
async function noIndexScratch(path: string) {
|
|
163
|
+
const entries = await readdir(path + ".ltx", { withFileTypes: true });
|
|
164
|
+
assert(
|
|
165
|
+
!entries.some((entry) => entry.isFile() && /^index-[a-zA-Z0-9]{6}$/.test(entry.name)),
|
|
166
|
+
"index scratch file leaked",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
156
169
|
async function run(path: string, sql: string, extra: Record<string, string> = {}) {
|
|
157
170
|
const child = Bun.spawn([binary, path], {
|
|
158
171
|
env: { ...process.env, ...extra },
|
|
@@ -169,6 +182,7 @@ async function run(path: string, sql: string, extra: Record<string, string> = {}
|
|
|
169
182
|
]);
|
|
170
183
|
assert.equal(code, 0, error);
|
|
171
184
|
assert(out.startsWith("ready\n"), out);
|
|
185
|
+
await noIndexScratch(path);
|
|
172
186
|
return out.split("\n").slice(1).filter(Boolean);
|
|
173
187
|
}
|
|
174
188
|
async function stoppedCommit(path: string, phase: string, count = 1) {
|
|
@@ -212,6 +226,10 @@ async function stoppedCommit(path: string, phase: string, count = 1) {
|
|
|
212
226
|
}
|
|
213
227
|
child.kill("SIGKILL");
|
|
214
228
|
await child.exited;
|
|
229
|
+
if (phase === "index-created") {
|
|
230
|
+
const entries = await readdir(path + ".ltx", { withFileTypes: true });
|
|
231
|
+
assert.equal(entries.filter((entry) => entry.isFile() && /^index-[a-zA-Z0-9]{6}$/.test(entry.name)).length, 1);
|
|
232
|
+
} else await noIndexScratch(path);
|
|
215
233
|
assert(stopped, "did not reach crash boundary " + phase);
|
|
216
234
|
return epoch;
|
|
217
235
|
}
|
|
@@ -241,6 +259,45 @@ try {
|
|
|
241
259
|
const cleanPosition = await readFile(root + "/position", "utf8");
|
|
242
260
|
assert(cleanPosition.split("\n")[2].startsWith("C "));
|
|
243
261
|
const finishedBefore = await readdir(join(root, epoch));
|
|
262
|
+
// SIGKILL can land between mkstemp and unlink. Only the next lock owner may
|
|
263
|
+
// discard that reserved scratch name; unrelated files and links stay intact.
|
|
264
|
+
const strayIndex = join(root, "index-abc123");
|
|
265
|
+
await writeFile(strayIndex, "stale index");
|
|
266
|
+
const unrelatedIndex = join(root, "index-not-scratch");
|
|
267
|
+
await writeFile(unrelatedIndex, "keep");
|
|
268
|
+
const indexDirectory = join(root, "index-DIR123");
|
|
269
|
+
await mkdir(indexDirectory);
|
|
270
|
+
await writeFile(join(indexDirectory, "keep"), "keep directory");
|
|
271
|
+
const outside = join(work, "outside-index-target");
|
|
272
|
+
await writeFile(outside, "keep target");
|
|
273
|
+
const indexLink = join(root, "index-LNK123");
|
|
274
|
+
await symlink(outside, indexLink);
|
|
275
|
+
const owner = Bun.spawn([binary, path], {
|
|
276
|
+
stdin: "pipe",
|
|
277
|
+
stdout: "pipe",
|
|
278
|
+
stderr: "pipe",
|
|
279
|
+
env: { ...process.env, SB_LTX_TEST_HOLD_LOCK: "1" },
|
|
280
|
+
});
|
|
281
|
+
const lockReader = owner.stdout.getReader();
|
|
282
|
+
try {
|
|
283
|
+
const ready = await lockReader.read();
|
|
284
|
+
assert.equal(new TextDecoder().decode(ready.value), "locked\n");
|
|
285
|
+
const blocked = Bun.spawnSync([binary, path], { stdin: "ignore" });
|
|
286
|
+
assert.equal(blocked.exitCode, 4, "non-owner cannot initialize capture");
|
|
287
|
+
assert.equal(await readFile(strayIndex, "utf8"), "stale index", "non-owner must not sweep indexes");
|
|
288
|
+
} finally {
|
|
289
|
+
owner.stdin.end();
|
|
290
|
+
await owner.exited;
|
|
291
|
+
lockReader.releaseLock();
|
|
292
|
+
}
|
|
293
|
+
await run(path, "SELECT 1;\n");
|
|
294
|
+
assert(!(await readdir(root)).includes("index-abc123"), "resume must reclaim stale index");
|
|
295
|
+
assert.equal(await readFile(unrelatedIndex, "utf8"), "keep");
|
|
296
|
+
assert.equal(await readFile(join(indexDirectory, "keep"), "utf8"), "keep directory");
|
|
297
|
+
assert.equal(await readFile(indexLink, "utf8"), "keep target");
|
|
298
|
+
assert.equal(await readFile(outside, "utf8"), "keep target");
|
|
299
|
+
assert.equal(await readFile(root + "/position", "utf8"), cleanPosition);
|
|
300
|
+
assert.deepEqual(await readdir(join(root, epoch)), finishedBefore);
|
|
244
301
|
for (let n = 0; n < 5; n++) {
|
|
245
302
|
await run(path, "SELECT 1;\n");
|
|
246
303
|
assert.equal(await readFile(root + "/position", "utf8"), cleanPosition, "idle restart must not rewrite position");
|
|
@@ -361,6 +418,7 @@ try {
|
|
|
361
418
|
failedDb.close();
|
|
362
419
|
const phases = [
|
|
363
420
|
"wal-committed",
|
|
421
|
+
"index-created",
|
|
364
422
|
"ltx-written",
|
|
365
423
|
"ltx-fsynced",
|
|
366
424
|
"ltx-published",
|
|
@@ -408,7 +466,7 @@ try {
|
|
|
408
466
|
assert.equal(pdb.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
|
|
409
467
|
pdb.close();
|
|
410
468
|
console.log(
|
|
411
|
-
"PASS restart:
|
|
469
|
+
"PASS restart: 11 SIGKILL durability boundaries, matching-prefix resume, orphan/missing/reset fallback, pending committed WAL tail captured before serving",
|
|
412
470
|
);
|
|
413
471
|
console.log(
|
|
414
472
|
"PASS native LTX: 102-file chain, independent CRC/index checks, byte-exact restore, integrity, hard WAL ceiling and oversized transaction rollback",
|