@retaw106/mmc-oc 2.4.0 → 2.6.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/dist/{context-c72brxe5.js → context-cfbjrr0n.js} +1636 -513
- package/dist/features/magic-context/compaction-marker.d.ts +34 -6
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts +6 -2
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +0 -26
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts +5 -1
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/features/magic-context/session-decision-calibration.d.ts.map +1 -1
- package/dist/features/magic-context/session-retention.fixture.d.ts +4 -0
- package/dist/features/magic-context/session-retention.fixture.d.ts.map +1 -0
- package/dist/features/magic-context/storage-db.d.ts +2 -0
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-host-instruction-retention.d.ts +4 -0
- package/dist/features/magic-context/storage-host-instruction-retention.d.ts.map +1 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts +21 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +2 -2
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-native-replay.d.ts +2 -0
- package/dist/features/magic-context/storage-native-replay.d.ts.map +1 -1
- package/dist/features/magic-context/storage-session-tables.d.ts +5 -4
- package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts +113 -58
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
- package/dist/features/magic-context/work-metrics.d.ts +5 -3
- package/dist/features/magic-context/work-metrics.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +32 -0
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/ask-answer-notice.d.ts +25 -0
- package/dist/hooks/magic-context/ask-answer-notice.d.ts.map +1 -0
- package/dist/hooks/magic-context/auto-search-hint.d.ts +3 -0
- package/dist/hooks/magic-context/auto-search-hint.d.ts.map +1 -1
- package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts +5 -5
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-marker-publication.d.ts +14 -0
- package/dist/hooks/magic-context/compaction-marker-publication.d.ts.map +1 -0
- package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -2
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +11 -0
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/diagnostic-log-state.d.ts +4 -0
- package/dist/hooks/magic-context/diagnostic-log-state.d.ts.map +1 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
- package/dist/hooks/magic-context/file-token-coverage.d.ts +11 -0
- package/dist/hooks/magic-context/file-token-coverage.d.ts.map +1 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +11 -3
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-history-wait.d.ts +16 -0
- package/dist/hooks/magic-context/historian-history-wait.d.ts.map +1 -0
- package/dist/hooks/magic-context/history-boundary-repair.d.ts +1 -1
- package/dist/hooks/magic-context/history-boundary-repair.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/host-served-rows.d.ts +10 -0
- package/dist/hooks/magic-context/host-served-rows.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-measured-request.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-recovery-fence.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts +3 -1
- package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay-fit.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts +3 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/nudge-evaluation-log.d.ts +3 -0
- package/dist/hooks/magic-context/nudge-evaluation-log.d.ts.map +1 -0
- package/dist/hooks/magic-context/pass-outcome.d.ts +2 -0
- package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -1
- package/dist/hooks/magic-context/pending-drop-eligibility.d.ts.map +1 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts +2 -0
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts +8 -2
- package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +20 -0
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts +3 -3
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
- package/dist/hooks/magic-context/signed-thinking-fit-snapshot.d.ts +42 -0
- package/dist/hooks/magic-context/signed-thinking-fit-snapshot.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-date-refresh.d.ts +3 -2
- package/dist/hooks/magic-context/system-date-refresh.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-sticky-date.d.ts +4 -0
- package/dist/hooks/magic-context/system-prompt-sticky-date.d.ts.map +1 -0
- package/dist/hooks/magic-context/tag-content-primitives.d.ts +1 -1
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +17 -2
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/text-complete.d.ts +1 -1
- package/dist/hooks/magic-context/text-complete.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +3 -2
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +6 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-stage-logger.d.ts +3 -0
- package/dist/hooks/magic-context/transform-stage-logger.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -2
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/{index-27ddyx39.js → index-1np16gxq.js} +1 -1
- package/dist/{index-ykq24x1n.js → index-8rmt6s4f.js} +78 -32
- package/dist/{index-dwh99z66.js → index-pte3cfs9.js} +194 -5
- package/dist/{index-r7s7d350.js → index-s3ebcy63.js} +1 -1
- package/dist/{index-48e9znrc.js → index-t0zx3tgz.js} +481 -109
- package/dist/{index-kahkxcqw.js → index-xk1dz6q9.js} +870 -315
- package/dist/index.js +2 -2
- package/dist/{message-time-backfill-j96x1sab.js → message-time-backfill-g9py24yt.js} +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/subagent-tool-policy.d.ts +4 -0
- package/dist/plugin/subagent-tool-policy.d.ts.map +1 -1
- package/dist/{read-session-chunk-t8bg6wkz.js → read-session-chunk-2s8x6xps.js} +3 -3
- package/dist/{recomp-orchestrator-k760qy1n.js → recomp-orchestrator-51xyaexp.js} +6 -6
- package/dist/{send-session-notification-njjf3b4s.js → send-session-notification-vdppp5x6.js} +3 -3
- package/dist/shared/model-suggestion-retry.d.ts +2 -0
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/rpc-discovery-cleanup.d.ts +3 -0
- package/dist/shared/rpc-discovery-cleanup.d.ts.map +1 -0
- package/dist/shared/rpc-server.d.ts +2 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/search-text.d.ts +8 -0
- package/dist/shared/search-text.d.ts.map +1 -0
- package/dist/shared/sqlite.d.ts +6 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/storage-file-mutex.d.ts.map +1 -1
- package/dist/shared/system-directive.d.ts +2 -0
- package/dist/shared/system-directive.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts +2 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/transcript.d.ts +3 -1
- package/dist/shared/transcript.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tools/parameter-descriptions.d.ts +6 -6
- package/dist/tools/parameter-descriptions.d.ts.map +1 -1
- package/dist/v2/fold/native-replay.d.ts +11 -2
- package/dist/v2/fold/native-replay.d.ts.map +1 -1
- package/dist/v2/fold/policy.d.ts.map +1 -1
- package/dist/v2/fold/restore.d.ts.map +1 -1
- package/dist/v2/hooks/context.d.ts +4 -0
- package/dist/v2/hooks/context.d.ts.map +1 -1
- package/dist/v2/hooks/generate.d.ts +36 -1
- package/dist/v2/hooks/generate.d.ts.map +1 -1
- package/dist/v2/hooks/lkg-system.d.ts +35 -4
- package/dist/v2/hooks/lkg-system.d.ts.map +1 -1
- package/dist/v2/hooks/payload.d.ts +10 -1
- package/dist/v2/hooks/payload.d.ts.map +1 -1
- package/dist/v2/hooks/store.d.ts +2 -0
- package/dist/v2/hooks/store.d.ts.map +1 -1
- package/dist/v2/hooks/types.d.ts +12 -0
- package/dist/v2/hooks/types.d.ts.map +1 -1
- package/dist/v2/message-times.d.ts +17 -0
- package/dist/v2/message-times.d.ts.map +1 -0
- package/dist/v2/server.js +4692 -2394
- package/dist/v2/store-reader.d.ts +21 -0
- package/dist/v2/store-reader.d.ts.map +1 -1
- package/dist/web/main.js +307 -12
- package/dist/{wrapup-orchestrator-b2gswatj.js → wrapup-orchestrator-g0mww1h4.js} +6 -6
- package/package.json +1 -1
- package/src/config/latch-permanence-guard.test.ts +4 -5
- package/src/shared/model-suggestion-retry.test.ts +52 -0
- package/src/shared/model-suggestion-retry.ts +8 -3
- package/src/shared/prompt-surface-a1-golden.md +6 -7
- package/src/shared/rpc-discovery-cleanup.test.ts +125 -0
- package/src/shared/rpc-discovery-cleanup.ts +57 -0
- package/src/shared/rpc-server.ts +36 -13
- package/src/shared/search-text.test.ts +30 -0
- package/src/shared/search-text.ts +62 -0
- package/src/shared/sqlite-transaction-mode-guard.test.ts +2 -0
- package/src/shared/sqlite-turn-deadline.test.ts +286 -0
- package/src/shared/sqlite.ts +237 -9
- package/src/shared/storage-file-mutex.test.ts +51 -2
- package/src/shared/storage-file-mutex.ts +11 -1
- package/src/shared/system-directive.test.ts +37 -0
- package/src/shared/system-directive.ts +19 -1
- package/src/shared/tag-transcript.ts +63 -8
- package/src/shared/transcript.ts +1 -1
- package/src/shared/write-transaction-attribution.test.ts +1 -6
- package/dist/features/magic-context/message-index-sweep.d.ts +0 -11
- package/dist/features/magic-context/message-index-sweep.d.ts.map +0 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Database as NativeDatabase } from "bun:sqlite";
|
|
2
|
+
import { expect, spyOn, test } from "bun:test";
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
createSqliteTurnDatabase,
|
|
8
|
+
Database,
|
|
9
|
+
hasSqliteTurnDeadlineRouting,
|
|
10
|
+
withoutSqliteTransformPass,
|
|
11
|
+
withSqliteBackgroundWriter,
|
|
12
|
+
withSqliteTransformPass,
|
|
13
|
+
withSqliteTurnDeadline,
|
|
14
|
+
} from "./sqlite";
|
|
15
|
+
import { createTestTempDirFromPath } from "./test-temp-dir";
|
|
16
|
+
|
|
17
|
+
async function fixture(run: (db: Database, path: string) => Promise<void>) {
|
|
18
|
+
const root = createTestTempDirFromPath(join(tmpdir(), "sqlite-turn-deadline-"));
|
|
19
|
+
const path = join(root, "context.db");
|
|
20
|
+
const db = createSqliteTurnDatabase(path);
|
|
21
|
+
db.exec(
|
|
22
|
+
"PRAGMA journal_mode=DELETE; PRAGMA busy_timeout=900; CREATE TABLE t(value); INSERT INTO t VALUES(1)",
|
|
23
|
+
);
|
|
24
|
+
try {
|
|
25
|
+
await run(db, path);
|
|
26
|
+
} finally {
|
|
27
|
+
db.close();
|
|
28
|
+
rmSync(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function hold(path: string, reader = false) {
|
|
33
|
+
const child = Bun.spawn(
|
|
34
|
+
[
|
|
35
|
+
process.execPath,
|
|
36
|
+
"-e",
|
|
37
|
+
`
|
|
38
|
+
const { Database } = require('bun:sqlite');
|
|
39
|
+
const db = new Database(process.argv[1]);
|
|
40
|
+
db.exec(${JSON.stringify(reader ? "BEGIN; SELECT * FROM t" : "BEGIN EXCLUSIVE")});
|
|
41
|
+
console.log('HELD');
|
|
42
|
+
setInterval(() => {}, 1000);
|
|
43
|
+
`,
|
|
44
|
+
path,
|
|
45
|
+
],
|
|
46
|
+
{ stdout: "pipe", stderr: "pipe", windowsHide: true },
|
|
47
|
+
);
|
|
48
|
+
const output = child.stdout.getReader();
|
|
49
|
+
const first = await output.read();
|
|
50
|
+
output.releaseLock();
|
|
51
|
+
if (!new TextDecoder().decode(first.value).includes("HELD")) {
|
|
52
|
+
child.kill();
|
|
53
|
+
throw new Error(`holder failed: ${await new Response(child.stderr).text()}`);
|
|
54
|
+
}
|
|
55
|
+
return async () => {
|
|
56
|
+
child.kill();
|
|
57
|
+
await child.exited;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function timeout(db: Database): number {
|
|
62
|
+
return (db.prepare("PRAGMA busy_timeout").get() as { timeout: number }).timeout;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bounded(operation: () => unknown) {
|
|
66
|
+
const start = performance.now();
|
|
67
|
+
expect(() =>
|
|
68
|
+
withSqliteTurnDeadline(() => Math.max(0, 80 - (performance.now() - start)), operation),
|
|
69
|
+
).toThrow(/locked|busy/i);
|
|
70
|
+
expect(performance.now() - start).toBeLessThan(400);
|
|
71
|
+
expect(performance.now() - start).toBeGreaterThan(40);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("ordinary connections keep native cached SELECT methods and do not opt in", () => {
|
|
75
|
+
const db = new Database(":memory:");
|
|
76
|
+
const native = new NativeDatabase(":memory:");
|
|
77
|
+
try {
|
|
78
|
+
expect(hasSqliteTurnDeadlineRouting(db)).toBe(false);
|
|
79
|
+
expect(db.prepare("SELECT 1").get).toBe(native.prepare("SELECT 1").get);
|
|
80
|
+
} finally {
|
|
81
|
+
db.close();
|
|
82
|
+
native.close();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("native deadline timeout is read once, not on every cached SELECT", () => {
|
|
87
|
+
let reads = 0;
|
|
88
|
+
const prepare = Database.prototype.prepare;
|
|
89
|
+
const spy = spyOn(Database.prototype, "prepare").mockImplementation(function (
|
|
90
|
+
this: Database,
|
|
91
|
+
sql: string,
|
|
92
|
+
) {
|
|
93
|
+
const statement = prepare.call(this, sql);
|
|
94
|
+
if (sql === "PRAGMA busy_timeout") {
|
|
95
|
+
const get = statement.get.bind(statement);
|
|
96
|
+
statement.get = (...args: unknown[]) => {
|
|
97
|
+
reads++;
|
|
98
|
+
return get(...args);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return statement;
|
|
102
|
+
});
|
|
103
|
+
let db: Database | undefined;
|
|
104
|
+
try {
|
|
105
|
+
db = createSqliteTurnDatabase(":memory:");
|
|
106
|
+
db.exec("PRAGMA busy_timeout=900");
|
|
107
|
+
const read = db.prepare("SELECT 1 AS value");
|
|
108
|
+
withSqliteTurnDeadline(
|
|
109
|
+
() => 80,
|
|
110
|
+
() => {
|
|
111
|
+
for (let i = 0; i < 100; i++) expect(read.get()).toEqual({ value: 1 });
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
expect(reads).toBe(1);
|
|
115
|
+
} finally {
|
|
116
|
+
db?.close();
|
|
117
|
+
spy.mockRestore();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
for (const method of ["get", "all", "run", "values", "iterate"] as const) {
|
|
122
|
+
test(`turn deadline bounds cached SELECT ${method} and restores timeout`, async () => {
|
|
123
|
+
await fixture(async (db, path) => {
|
|
124
|
+
expect(hasSqliteTurnDeadlineRouting(db)).toBe(true);
|
|
125
|
+
const statement: any = db.prepare("SELECT * FROM t");
|
|
126
|
+
const release = await hold(path);
|
|
127
|
+
try {
|
|
128
|
+
bounded(() =>
|
|
129
|
+
method === "iterate" ? statement.iterate().next() : statement[method](),
|
|
130
|
+
);
|
|
131
|
+
expect(timeout(db)).toBe(900);
|
|
132
|
+
} finally {
|
|
133
|
+
await release();
|
|
134
|
+
}
|
|
135
|
+
expect(statement.get()).toEqual({ value: 1 });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test("turn deadline bounds exec, prepare, deferred reads and COMMIT", async () => {
|
|
141
|
+
await fixture(async (db, path) => {
|
|
142
|
+
const read = db.prepare("SELECT * FROM t");
|
|
143
|
+
const fresh = createSqliteTurnDatabase(path);
|
|
144
|
+
fresh.exec("PRAGMA busy_timeout=900");
|
|
145
|
+
const release = await hold(path);
|
|
146
|
+
try {
|
|
147
|
+
bounded(() => db.exec("SELECT * FROM t"));
|
|
148
|
+
bounded(() => fresh.prepare("SELECT * FROM t"));
|
|
149
|
+
db.exec("BEGIN DEFERRED");
|
|
150
|
+
bounded(() => read.get());
|
|
151
|
+
db.exec("ROLLBACK");
|
|
152
|
+
} finally {
|
|
153
|
+
fresh.close();
|
|
154
|
+
await release();
|
|
155
|
+
}
|
|
156
|
+
db.exec("BEGIN IMMEDIATE; UPDATE t SET value=2");
|
|
157
|
+
const releaseReader = await hold(path, true);
|
|
158
|
+
try {
|
|
159
|
+
bounded(() => db.exec("COMMIT"));
|
|
160
|
+
expect(db.inTransaction).toBe(true);
|
|
161
|
+
expect(timeout(db)).toBe(900);
|
|
162
|
+
db.exec("ROLLBACK");
|
|
163
|
+
} finally {
|
|
164
|
+
await releaseReader();
|
|
165
|
+
}
|
|
166
|
+
expect(read.get()).toEqual({ value: 1 });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("timeout mirror follows setters, prepare-time effects and failed script prefixes", async () => {
|
|
171
|
+
await fixture(async (db, path) => {
|
|
172
|
+
db.exec(
|
|
173
|
+
"PRAGMA main.busy_timeout(850); SELECT '; PRAGMA busy_timeout=0;'; -- PRAGMA busy_timeout=1",
|
|
174
|
+
);
|
|
175
|
+
const setter = db.prepare("PRAGMA busy_timeout=800");
|
|
176
|
+
expect(timeout(db)).toBe(800);
|
|
177
|
+
const success = () =>
|
|
178
|
+
withSqliteTurnDeadline(
|
|
179
|
+
() => 80,
|
|
180
|
+
() => db.prepare("SELECT 1").get(),
|
|
181
|
+
);
|
|
182
|
+
success();
|
|
183
|
+
expect(timeout(db)).toBe(800);
|
|
184
|
+
db.exec("PRAGMA busy_timeout=750");
|
|
185
|
+
setter.get();
|
|
186
|
+
const before = timeout(db);
|
|
187
|
+
success();
|
|
188
|
+
expect(timeout(db)).toBe(before);
|
|
189
|
+
const iterator = db.prepare("PRAGMA busy_timeout=600").iterate();
|
|
190
|
+
db.exec("PRAGMA busy_timeout=500");
|
|
191
|
+
iterator.next();
|
|
192
|
+
const after = timeout(db);
|
|
193
|
+
success();
|
|
194
|
+
expect(timeout(db)).toBe(after);
|
|
195
|
+
iterator.return?.();
|
|
196
|
+
expect(() => db.exec("PRAGMA busy_timeout=700; SELECT * FROM missing_table")).toThrow();
|
|
197
|
+
const read = db.prepare("SELECT * FROM t");
|
|
198
|
+
const release = await hold(path);
|
|
199
|
+
try {
|
|
200
|
+
bounded(() => read.get());
|
|
201
|
+
expect(timeout(db)).toBe(700);
|
|
202
|
+
} finally {
|
|
203
|
+
await release();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("deadline leaves zero-wait probes and existing writer acquisition semantics intact", async () => {
|
|
209
|
+
await fixture(async (db, path) => {
|
|
210
|
+
const release = await hold(path);
|
|
211
|
+
try {
|
|
212
|
+
bounded(() => withSqliteTransformPass(() => db.exec("BEGIN IMMEDIATE")));
|
|
213
|
+
expect(timeout(db)).toBe(900);
|
|
214
|
+
db.exec("PRAGMA busy_timeout=0");
|
|
215
|
+
const start = performance.now();
|
|
216
|
+
expect(() =>
|
|
217
|
+
withSqliteTurnDeadline(
|
|
218
|
+
() => 80,
|
|
219
|
+
() => db.exec("SELECT * FROM t"),
|
|
220
|
+
),
|
|
221
|
+
).toThrow();
|
|
222
|
+
expect(performance.now() - start).toBeLessThan(100);
|
|
223
|
+
expect(timeout(db)).toBe(0);
|
|
224
|
+
} finally {
|
|
225
|
+
await release();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const detach of [withoutSqliteTransformPass, withSqliteBackgroundWriter]) {
|
|
231
|
+
test(`detached native reads retain configured timeout (${detach.name})`, async () => {
|
|
232
|
+
await fixture(async (db, path) => {
|
|
233
|
+
const read = db.prepare("SELECT * FROM t");
|
|
234
|
+
const release = await hold(path);
|
|
235
|
+
try {
|
|
236
|
+
const start = performance.now();
|
|
237
|
+
expect(() =>
|
|
238
|
+
withSqliteTurnDeadline(
|
|
239
|
+
() => 0,
|
|
240
|
+
() => detach(() => read.get()),
|
|
241
|
+
),
|
|
242
|
+
).toThrow();
|
|
243
|
+
expect(performance.now() - start).toBeGreaterThan(700);
|
|
244
|
+
expect(timeout(db)).toBe(900);
|
|
245
|
+
} finally {
|
|
246
|
+
await release();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const outcome of ["success", "throw", "reject"] as const) {
|
|
253
|
+
test(`completed native lease stops consulting request callback (${outcome})`, async () => {
|
|
254
|
+
const db = createSqliteTurnDatabase(":memory:");
|
|
255
|
+
let reads = 0;
|
|
256
|
+
let descendant!: () => unknown;
|
|
257
|
+
let resume!: () => void;
|
|
258
|
+
const gate = new Promise<void>((resolve) => {
|
|
259
|
+
resume = resolve;
|
|
260
|
+
});
|
|
261
|
+
let pending!: Promise<unknown>;
|
|
262
|
+
try {
|
|
263
|
+
try {
|
|
264
|
+
await withSqliteTurnDeadline(
|
|
265
|
+
() => {
|
|
266
|
+
reads++;
|
|
267
|
+
return 80;
|
|
268
|
+
},
|
|
269
|
+
() => {
|
|
270
|
+
pending = gate.then(() => descendant());
|
|
271
|
+
descendant = () => db.prepare("SELECT 1").get();
|
|
272
|
+
if (outcome === "throw") throw new Error("sync failure");
|
|
273
|
+
if (outcome === "reject") return Promise.reject(new Error("async failure"));
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
expect(String(error)).toContain("failure");
|
|
278
|
+
}
|
|
279
|
+
resume();
|
|
280
|
+
await pending;
|
|
281
|
+
expect(reads).toBe(0);
|
|
282
|
+
} finally {
|
|
283
|
+
db.close();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
package/src/shared/sqlite.ts
CHANGED
|
@@ -393,11 +393,187 @@ export function getTagIdentityWriteGeneration(): number {
|
|
|
393
393
|
return tagIdentityWriteGeneration;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
396
|
+
/** Only Pi serving connections need a mirror of their native busy handler. */
|
|
397
|
+
const busyTimeoutStates = new WeakMap<Database, { current: number }>();
|
|
398
|
+
const BUSY_TIMEOUT_QUERY =
|
|
399
|
+
/^\s*PRAGMA\s+(?:(?:\w+|"[^"]+"|`[^`]+`|\[[^\]]+\])\s*\.\s*)?(?:busy_timeout|"busy_timeout"|`busy_timeout`|\[busy_timeout\])\s*;?\s*$/i;
|
|
400
|
+
const BUSY_TIMEOUT_SETTER =
|
|
401
|
+
/^\s*PRAGMA\s+(?:(?:\w+|"[^"]+"|`[^`]+`|\[[^\]]+\])\s*\.\s*)?(?:busy_timeout|"busy_timeout"|`busy_timeout`|\[busy_timeout\])\s*(?==|\()/i;
|
|
402
|
+
|
|
403
|
+
function literalBusyTimeout(sql: string): number | undefined {
|
|
404
|
+
const head = BUSY_TIMEOUT_SETTER.exec(sql);
|
|
405
|
+
if (!head) return undefined;
|
|
406
|
+
const match = /^(?:=\s*|\(\s*)(?:'(-?\d+)'|"(-?\d+)"|(-?\d+))\s*\)?\s*;?\s*$/.exec(
|
|
407
|
+
sql.slice(head[0].length),
|
|
408
|
+
);
|
|
409
|
+
if (!match) return undefined;
|
|
410
|
+
const value = Number(match[1] ?? match[2] ?? match[3]);
|
|
411
|
+
return value < 0 ? 0 : value <= 0x7fffffff ? value : undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Split timeout-setting scripts without mistaking quoted text or comments for pragmas. */
|
|
415
|
+
function busyTimeoutStatements(sql: string): string[] {
|
|
416
|
+
const statements: string[] = [];
|
|
417
|
+
let statement = "";
|
|
418
|
+
let quote = "";
|
|
419
|
+
for (let i = 0; i < sql.length; i++) {
|
|
420
|
+
const char = sql[i];
|
|
421
|
+
if (quote) {
|
|
422
|
+
statement += char;
|
|
423
|
+
if (char === quote) {
|
|
424
|
+
if (sql[i + 1] === quote) statement += sql[++i];
|
|
425
|
+
else quote = "";
|
|
426
|
+
}
|
|
427
|
+
} else if (char === "'" || char === '"' || char === "`" || char === "[") {
|
|
428
|
+
quote = char === "[" ? "]" : char;
|
|
429
|
+
statement += char;
|
|
430
|
+
} else if (char === "-" && sql[i + 1] === "-") {
|
|
431
|
+
while (i + 1 < sql.length && sql[i + 1] !== "\n") i++;
|
|
432
|
+
statement += " ";
|
|
433
|
+
} else if (char === "/" && sql[i + 1] === "*") {
|
|
434
|
+
i += 2;
|
|
435
|
+
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
|
436
|
+
i++;
|
|
437
|
+
statement += " ";
|
|
438
|
+
} else if (char === ";") {
|
|
439
|
+
statements.push(statement);
|
|
440
|
+
statement = "";
|
|
441
|
+
} else statement += char;
|
|
442
|
+
}
|
|
443
|
+
statements.push(statement);
|
|
444
|
+
return statements.filter((part) => part.trim().length > 0);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Install the native deadline layer only on a connection that will serve Pi turns. */
|
|
448
|
+
function installNativeDeadlineRouting(db: BetterSqlite3.Database): void {
|
|
449
|
+
const rawExec = db.exec.bind(db);
|
|
450
|
+
const rawPrepare = db.prepare.bind(db);
|
|
451
|
+
const readTimeout = (): number => {
|
|
452
|
+
const statement = rawPrepare("PRAGMA busy_timeout");
|
|
453
|
+
try {
|
|
454
|
+
return Object.values(statement.get() as Record<string, number>)[0];
|
|
455
|
+
} finally {
|
|
456
|
+
(statement as Partial<FinalizableStatement>).finalize?.();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const timeoutState = { current: readTimeout() };
|
|
460
|
+
busyTimeoutStates.set(db, timeoutState);
|
|
461
|
+
const setBusyTimeout = (timeout: number): void => {
|
|
462
|
+
rawExec(`PRAGMA busy_timeout=${timeout}`);
|
|
463
|
+
timeoutState.current = timeout;
|
|
464
|
+
};
|
|
465
|
+
const withinDeadline = <T>(operation: () => T): T => {
|
|
466
|
+
const deadline = nativeWaitScope?.getStore();
|
|
467
|
+
if (!deadline?.active || !deadline.remainingMs) return operation();
|
|
468
|
+
const remaining = deadline.remainingMs();
|
|
469
|
+
if (remaining === Infinity) return operation();
|
|
470
|
+
const previous = timeoutState.current;
|
|
471
|
+
const timeout = Math.min(previous, Math.max(0, Math.floor(remaining)));
|
|
472
|
+
if (timeout === previous) return operation();
|
|
473
|
+
setBusyTimeout(timeout);
|
|
474
|
+
try {
|
|
475
|
+
return operation();
|
|
476
|
+
} finally {
|
|
477
|
+
setBusyTimeout(previous);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
Object.defineProperty(db, "exec", {
|
|
481
|
+
configurable: true,
|
|
482
|
+
writable: true,
|
|
483
|
+
value: (sql: string) => {
|
|
484
|
+
const parts = /\bbusy_timeout\b/i.test(sql) ? busyTimeoutStatements(sql) : [];
|
|
485
|
+
if (parts.length === 1 && BUSY_TIMEOUT_QUERY.test(parts[0])) return rawExec(sql);
|
|
486
|
+
const singleSetter = parts.length === 1 && BUSY_TIMEOUT_SETTER.test(parts[0]);
|
|
487
|
+
const setting = singleSetter ? literalBusyTimeout(parts[0]) : undefined;
|
|
488
|
+
if (singleSetter) {
|
|
489
|
+
const result = rawExec(sql);
|
|
490
|
+
timeoutState.current = setting ?? readTimeout();
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
if (!/\bbusy_timeout\b/i.test(sql)) return withinDeadline(() => rawExec(sql));
|
|
494
|
+
const setters = parts.filter((part) => BUSY_TIMEOUT_SETTER.test(part));
|
|
495
|
+
return withinDeadline(() => {
|
|
496
|
+
try {
|
|
497
|
+
const result = rawExec(sql);
|
|
498
|
+
if (setters.length)
|
|
499
|
+
timeoutState.current =
|
|
500
|
+
literalBusyTimeout(setters[setters.length - 1]) ?? readTimeout();
|
|
501
|
+
return result;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
// exec can apply a timeout-setting prefix before a later statement
|
|
504
|
+
// fails. Only an ambiguous setting needs a native readback.
|
|
505
|
+
if (setters.length) timeoutState.current = readTimeout();
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
// Timeout introspection cannot wait on a database lock. Its getter must
|
|
512
|
+
// report the configured timeout, not a temporary cap that a caller might save.
|
|
513
|
+
const nativePrepare = (sql: string) =>
|
|
514
|
+
BUSY_TIMEOUT_QUERY.test(sql) || BUSY_TIMEOUT_SETTER.test(sql)
|
|
515
|
+
? rawPrepare(sql)
|
|
516
|
+
: withinDeadline(() => rawPrepare(sql));
|
|
517
|
+
Object.defineProperty(db, "prepare", {
|
|
518
|
+
configurable: true,
|
|
519
|
+
writable: true,
|
|
520
|
+
value: (sql: string) => {
|
|
521
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun adds values() to the common statement interface.
|
|
522
|
+
const statement: any = nativePrepare(sql);
|
|
523
|
+
const normalized = /\bbusy_timeout\b/i.test(sql)
|
|
524
|
+
? (busyTimeoutStatements(sql)[0] ?? sql)
|
|
525
|
+
: sql;
|
|
526
|
+
const setting = literalBusyTimeout(normalized);
|
|
527
|
+
const timeoutSetter = BUSY_TIMEOUT_SETTER.test(normalized);
|
|
528
|
+
const timeoutQuery = BUSY_TIMEOUT_QUERY.test(normalized);
|
|
529
|
+
// SQLite applies this PRAGMA while preparing it, before get/run.
|
|
530
|
+
if (timeoutSetter) timeoutState.current = setting ?? readTimeout();
|
|
531
|
+
for (const method of ["run", "get", "all", "values"] as const) {
|
|
532
|
+
const execute = statement[method]?.bind(statement);
|
|
533
|
+
if (!execute) continue;
|
|
534
|
+
Object.defineProperty(statement, method, {
|
|
535
|
+
configurable: true,
|
|
536
|
+
// biome-ignore lint/suspicious/noExplicitAny: retain native binding overloads.
|
|
537
|
+
value: (...args: any[]) => {
|
|
538
|
+
if (timeoutQuery) return execute(...args);
|
|
539
|
+
if (!timeoutSetter) return withinDeadline(() => execute(...args));
|
|
540
|
+
const result = execute(...args);
|
|
541
|
+
timeoutState.current = readTimeout();
|
|
542
|
+
return result;
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (typeof statement.iterate === "function") {
|
|
547
|
+
const iterate = statement.iterate.bind(statement);
|
|
548
|
+
Object.defineProperty(statement, "iterate", {
|
|
549
|
+
configurable: true,
|
|
550
|
+
// biome-ignore lint/suspicious/noExplicitAny: retain native binding overloads.
|
|
551
|
+
value: (...args: any[]) => {
|
|
552
|
+
const iterator =
|
|
553
|
+
!timeoutSetter && !timeoutQuery
|
|
554
|
+
? withinDeadline(() => iterate(...args))
|
|
555
|
+
: iterate(...args);
|
|
556
|
+
const next = iterator.next.bind(iterator);
|
|
557
|
+
if (timeoutSetter) timeoutState.current = readTimeout();
|
|
558
|
+
iterator.next = () => {
|
|
559
|
+
if (timeoutQuery) return next();
|
|
560
|
+
if (!timeoutSetter) return withinDeadline(next);
|
|
561
|
+
const result = next();
|
|
562
|
+
timeoutState.current = readTimeout();
|
|
563
|
+
return result;
|
|
564
|
+
};
|
|
565
|
+
return iterator;
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return statement;
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Route native Bun and Node transactions through the same acquisition retry.
|
|
575
|
+
* Native Bun wrappers execute BEGIN internally, so both runtimes use this wrapper.
|
|
576
|
+
* Deadline routing, when selected, is installed underneath it at connection open. */
|
|
401
577
|
function installTransactionRouting(db: BetterSqlite3.Database, readonly: boolean): void {
|
|
402
578
|
const nativeExec = db.exec.bind(db);
|
|
403
579
|
Object.defineProperty(db, "exec", {
|
|
@@ -555,6 +731,7 @@ function trackSqliteConnection(
|
|
|
555
731
|
db: BetterSqlite3.Database,
|
|
556
732
|
filename: unknown,
|
|
557
733
|
options: unknown,
|
|
734
|
+
turnDeadlines = false,
|
|
558
735
|
): BetterSqlite3.Database {
|
|
559
736
|
const originalClose = db.close.bind(db) as (...args: unknown[]) => unknown;
|
|
560
737
|
const sequence = nextSqliteConnectionSequence++;
|
|
@@ -572,6 +749,7 @@ function trackSqliteConnection(
|
|
|
572
749
|
((options as { readonly?: unknown }).readonly === true ||
|
|
573
750
|
(options as { readOnly?: unknown }).readOnly === true),
|
|
574
751
|
};
|
|
752
|
+
if (turnDeadlines) installNativeDeadlineRouting(db);
|
|
575
753
|
installTransactionRouting(db, metadata.readonly);
|
|
576
754
|
Object.defineProperty(db, "close", {
|
|
577
755
|
configurable: true,
|
|
@@ -654,6 +832,18 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
|
|
|
654
832
|
|
|
655
833
|
export const Database: typeof BetterSqlite3 = TrackedDatabase;
|
|
656
834
|
|
|
835
|
+
/** Open a Pi foreground connection with native-deadline routing before statements are prepared. */
|
|
836
|
+
export function createSqliteTurnDatabase(
|
|
837
|
+
filename?: string | Buffer,
|
|
838
|
+
options?: BetterSqlite3.Options,
|
|
839
|
+
): Database {
|
|
840
|
+
return trackSqliteConnection(new DatabaseImpl(filename, options), filename, options, true);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function hasSqliteTurnDeadlineRouting(db: Database): boolean {
|
|
844
|
+
return busyTimeoutStates.has(db);
|
|
845
|
+
}
|
|
846
|
+
|
|
657
847
|
/** The constructor path, without querying SQLite on the prompt thread. */
|
|
658
848
|
export function getSqliteDatabasePath(db: Database): string | null {
|
|
659
849
|
for (const connection of trackedSqliteConnections.values()) {
|
|
@@ -678,6 +868,10 @@ function pragmaValue(db: BetterSqlite3.Database, name: string): unknown {
|
|
|
678
868
|
}
|
|
679
869
|
|
|
680
870
|
function pragmaNumber(db: BetterSqlite3.Database, name: string): number | null {
|
|
871
|
+
if (name === "busy_timeout") {
|
|
872
|
+
const state = busyTimeoutStates.get(db);
|
|
873
|
+
if (state) return state.current;
|
|
874
|
+
}
|
|
681
875
|
try {
|
|
682
876
|
const value = pragmaValue(db, name);
|
|
683
877
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
@@ -884,6 +1078,38 @@ export function prepareCachedStatement(db: Database, sql: string): Statement {
|
|
|
884
1078
|
}
|
|
885
1079
|
|
|
886
1080
|
const privilegeDepth = new WeakMap<Database, number>();
|
|
1081
|
+
interface NativeWaitLease {
|
|
1082
|
+
active: boolean;
|
|
1083
|
+
remainingMs?: () => number;
|
|
1084
|
+
}
|
|
1085
|
+
let nativeWaitScope: AsyncLocalStorage<NativeWaitLease | undefined> | undefined;
|
|
1086
|
+
|
|
1087
|
+
function releaseNativeWaitLease(lease: NativeWaitLease): void {
|
|
1088
|
+
lease.active = false;
|
|
1089
|
+
// Detached descendants can retain their async-local lease after the turn.
|
|
1090
|
+
// Do not retain the completed request through its deadline callback.
|
|
1091
|
+
lease.remainingMs = undefined;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Bound native lock waits for an awaited turn, without changing background timeouts. */
|
|
1095
|
+
export function withSqliteTurnDeadline<T>(remainingMs: () => number, operation: () => T): T {
|
|
1096
|
+
const scope = (nativeWaitScope ??= new AsyncLocalStorage<NativeWaitLease | undefined>());
|
|
1097
|
+
const lease: NativeWaitLease = { active: true, remainingMs };
|
|
1098
|
+
return scope.run(lease, () => {
|
|
1099
|
+
try {
|
|
1100
|
+
const result = operation();
|
|
1101
|
+
if (result && typeof (result as { then?: unknown }).then === "function")
|
|
1102
|
+
return Promise.resolve(result).finally(
|
|
1103
|
+
releaseNativeWaitLease.bind(null, lease),
|
|
1104
|
+
) as T;
|
|
1105
|
+
releaseNativeWaitLease(lease);
|
|
1106
|
+
return result;
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
releaseNativeWaitLease(lease);
|
|
1109
|
+
throw error;
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
887
1113
|
const transformPassScope = new AsyncLocalStorage<
|
|
888
1114
|
| {
|
|
889
1115
|
active: boolean;
|
|
@@ -897,7 +1123,8 @@ const backgroundWriterScope = new AsyncLocalStorage<boolean>();
|
|
|
897
1123
|
|
|
898
1124
|
/** Opt in only a writer that can safely defer its work after a busy acquisition. */
|
|
899
1125
|
export function withSqliteBackgroundWriter<T>(operation: () => T): T {
|
|
900
|
-
return backgroundWriterScope.run(true, operation);
|
|
1126
|
+
if (!nativeWaitScope?.getStore()) return backgroundWriterScope.run(true, operation);
|
|
1127
|
+
return nativeWaitScope.run(undefined, () => backgroundWriterScope.run(true, operation));
|
|
901
1128
|
}
|
|
902
1129
|
|
|
903
1130
|
/** Only an awaited foreground transform may spend the extra acquisition budget.
|
|
@@ -934,7 +1161,8 @@ export function withSqliteTransformPass<T>(operation: () => T): T {
|
|
|
934
1161
|
|
|
935
1162
|
/** Start detached maintenance work without borrowing a foreground pass's budget. */
|
|
936
1163
|
export function withoutSqliteTransformPass<T>(operation: () => T): T {
|
|
937
|
-
return transformPassScope.run(undefined, operation);
|
|
1164
|
+
if (!nativeWaitScope?.getStore()) return transformPassScope.run(undefined, operation);
|
|
1165
|
+
return nativeWaitScope.run(undefined, () => transformPassScope.run(undefined, operation));
|
|
938
1166
|
}
|
|
939
1167
|
|
|
940
1168
|
/** Attach the adapter's existing generation/cancellation/deadline guard to writes. */
|
|
@@ -1233,7 +1461,7 @@ export async function withAsyncPrivilegedWriter<T>(
|
|
|
1233
1461
|
operation: () => T,
|
|
1234
1462
|
options: {
|
|
1235
1463
|
signal?: AbortSignal;
|
|
1236
|
-
beforeRetry?: (error: SqliteAcquisitionBusyError) => void
|
|
1464
|
+
beforeRetry?: (error: SqliteAcquisitionBusyError) => void | Promise<void>;
|
|
1237
1465
|
jitter?: boolean;
|
|
1238
1466
|
budgetMs?: number;
|
|
1239
1467
|
assertCurrentPass?: () => void;
|
|
@@ -1276,7 +1504,7 @@ export async function withAsyncPrivilegedWriter<T>(
|
|
|
1276
1504
|
if (entered || !(error instanceof SqliteAcquisitionBusyError)) throw error;
|
|
1277
1505
|
// Adapters may serve a validated saved request instead of waiting.
|
|
1278
1506
|
// This runs only after a failed BEGIN, never after callback writes.
|
|
1279
|
-
options.beforeRetry?.(error);
|
|
1507
|
+
await options.beforeRetry?.(error);
|
|
1280
1508
|
const elapsed = performance.now() - started;
|
|
1281
1509
|
if (elapsed >= budgetMs) {
|
|
1282
1510
|
reportSqliteDiagnostic(
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { afterAll, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { withStorageFileMutex } from "./storage-file-mutex";
|
|
7
|
+
import * as permissions from "./storage-permissions";
|
|
7
8
|
|
|
8
9
|
const directories: string[] = [];
|
|
9
10
|
const newDirectory = () => {
|
|
@@ -16,6 +17,54 @@ afterAll(() => {
|
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
describe("withStorageFileMutex", () => {
|
|
20
|
+
for (const removals of [1, 2, 3]) {
|
|
21
|
+
test(`ticket creation tolerates at most two directory cleanup races (${removals})`, () => {
|
|
22
|
+
const directory = newDirectory();
|
|
23
|
+
const write = permissions.writeStorageFileSync;
|
|
24
|
+
let attempts = 0;
|
|
25
|
+
let worked = false;
|
|
26
|
+
const writer = spyOn(permissions, "writeStorageFileSync").mockImplementation(
|
|
27
|
+
(...args) => {
|
|
28
|
+
if (attempts++ < removals) rmdirSync(directory);
|
|
29
|
+
return write(...args);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
try {
|
|
33
|
+
const acquire = () =>
|
|
34
|
+
withStorageFileMutex(directory, () => {
|
|
35
|
+
worked = true;
|
|
36
|
+
return "acquired";
|
|
37
|
+
});
|
|
38
|
+
if (removals <= 2) {
|
|
39
|
+
expect(acquire()).toBe("acquired");
|
|
40
|
+
// Includes the successful contender's atomic ticket rewrite.
|
|
41
|
+
expect(attempts).toBe(removals + 2);
|
|
42
|
+
expect(readdirSync(directory)).toEqual([]);
|
|
43
|
+
} else {
|
|
44
|
+
expect(acquire).toThrow("ENOENT");
|
|
45
|
+
expect(attempts).toBe(3);
|
|
46
|
+
}
|
|
47
|
+
expect(worked).toBe(removals <= 2);
|
|
48
|
+
} finally {
|
|
49
|
+
writer.mockRestore();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("does not retry other ticket write failures", () => {
|
|
55
|
+
const directory = newDirectory();
|
|
56
|
+
const error = Object.assign(new Error("denied"), { code: "EACCES" });
|
|
57
|
+
const writer = spyOn(permissions, "writeStorageFileSync").mockImplementation(() => {
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
expect(() => withStorageFileMutex(directory, () => 42)).toThrow(error);
|
|
62
|
+
expect(writer).toHaveBeenCalledTimes(1);
|
|
63
|
+
} finally {
|
|
64
|
+
writer.mockRestore();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
19
68
|
test("serializes two critical sections", () => {
|
|
20
69
|
const directory = newDirectory();
|
|
21
70
|
const orderPath = join(directory, "order");
|
|
@@ -68,7 +68,17 @@ export function withStorageFileMutex<T>(directory: string, work: () => T, timeou
|
|
|
68
68
|
const ticket: Ticket = { choosing: true, number: 0, birth, checksum: "" };
|
|
69
69
|
const encodedTicket = () =>
|
|
70
70
|
JSON.stringify({ ...ticket, checksum: ticketChecksum(name, ticket) });
|
|
71
|
-
|
|
71
|
+
for (let attempt = 0; ; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
writeStorageFileSync(ownPath, encodedTicket(), { flag: "wx", forcePrivate: true });
|
|
74
|
+
break;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A session cleanup can remove an empty directory between mkdir and
|
|
77
|
+
// ticket creation. No ticket exists yet, so retry before contending.
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || attempt >= 2) throw error;
|
|
79
|
+
ensurePrivateStorageDirectorySync(directory);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
72
82
|
const deadline = performance.now() + timeoutMs;
|
|
73
83
|
const peers = (): [string, Ticket | null][] => {
|
|
74
84
|
const found: [string, Ticket | null][] = [];
|