@oxidezap/baileyrs 0.2.11 → 0.2.13
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/lib/Bridge/index.d.ts +1 -1
- package/lib/Bridge/index.js +1 -1
- package/lib/Bridge/primitives.d.ts +18 -6
- package/lib/Bridge/primitives.js +26 -13
- package/lib/Bridge/schema.js +98 -16
- package/lib/Bridge/types.d.ts +11 -0
- package/lib/Compatibility/encode-proto.d.ts +7 -3
- package/lib/Compatibility/encode-proto.js +10 -5
- package/lib/Compatibility/proto-runtime.js +54 -7
- package/lib/Socket/events.js +1 -36
- package/lib/Socket/groups.js +35 -2
- package/lib/Socket/index.js +102 -18
- package/lib/Socket/terminal-close-reporter.d.ts +29 -8
- package/lib/Socket/terminal-close-reporter.js +31 -8
- package/lib/Socket/terminal-close.d.ts +2 -36
- package/lib/Socket/terminal-close.js +29 -5
- package/lib/Socket/unsupported-config.d.ts +1 -1
- package/lib/Socket/unsupported-config.js +1 -0
- package/lib/Types/Socket.d.ts +8 -0
- package/lib/Utils/use-bridge-store.d.ts +74 -3
- package/lib/Utils/use-bridge-store.js +541 -201
- package/package.json +3 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { mkdir, readdir, readFile,
|
|
3
|
-
import {
|
|
2
|
+
import { mkdir, open, readdir, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
4
5
|
/**
|
|
5
6
|
* Stores whose loss on SIGKILL produces undecryptable messages or
|
|
6
7
|
* permanent app-state divergence. Each entry below carries a reason —
|
|
7
|
-
* promoting a store to "critical"
|
|
8
|
-
* anything here that can be
|
|
8
|
+
* promoting a store to "critical" multiplies disk I/O (write + fsync +
|
|
9
|
+
* atomic rename per key), so don't add anything here that can be
|
|
10
|
+
* re-derived from the network.
|
|
9
11
|
*
|
|
10
12
|
* `session` / `identity` — Signal session ratchet steps. Lose
|
|
11
13
|
* one step → next inbound message from peer undecryptable.
|
|
@@ -37,113 +39,562 @@ const CRITICAL_STORES = new Set([
|
|
|
37
39
|
'mutation_mac'
|
|
38
40
|
]);
|
|
39
41
|
/**
|
|
40
|
-
* `ENOENT` is
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* `ENOENT` is legitimate only where absence is a valid answer: reading a
|
|
43
|
+
* key that was never written (or was deleted), deleting a key that is
|
|
44
|
+
* already gone, and enumerating a folder that was removed. Everywhere
|
|
45
|
+
* else — writes, flushes, non-ENOENT read/delete failures (ENOTDIR,
|
|
46
|
+
* EACCES, EIO, ENOSPC) — the error MUST propagate. A write that resolves
|
|
47
|
+
* without durable bytes while the caller believes it persisted silently
|
|
48
|
+
* loses Signal session state or message secrets.
|
|
45
49
|
*/
|
|
46
50
|
const isEnoent = (e) => e?.code === 'ENOENT';
|
|
51
|
+
const errnoOf = (e) => e?.code;
|
|
52
|
+
const bytesEqual = (a, b) => a.length === b.length && Buffer.compare(a, b) === 0;
|
|
53
|
+
// Real copy for both Uint8Array and Buffer. `Buffer.prototype.slice`
|
|
54
|
+
// deliberately returns a view over the same memory, so `.slice()` is NOT
|
|
55
|
+
// a valid snapshot for caller-owned buffers.
|
|
56
|
+
const copyBytes = (value) => Uint8Array.from(value);
|
|
57
|
+
/**
|
|
58
|
+
* Pure classifier for directory-barrier failures: does this error mean
|
|
59
|
+
* "this platform cannot sync directory handles" (degrade to process-crash
|
|
60
|
+
* atomicity) or "the durability barrier did not hold" (propagate)?
|
|
61
|
+
*
|
|
62
|
+
* - `ENOSYS` / `ENOTSUP` on any platform: the operation is not
|
|
63
|
+
* implemented — genuinely unsupported, safe to degrade.
|
|
64
|
+
* - `EINVAL` / `EPERM` / `EISDIR` on `win32` only: Windows directory
|
|
65
|
+
* handles reject open-for-read and FlushFileBuffers with these codes,
|
|
66
|
+
* so they are the documented platform fallback there. On Linux/macOS
|
|
67
|
+
* the same codes from a freshly opened directory handle mean something
|
|
68
|
+
* is genuinely wrong and they propagate.
|
|
69
|
+
* - Everything else propagates everywhere: `EIO`, `ENOSPC`, `EROFS`,
|
|
70
|
+
* `EACCES`, `ENOENT`, and notably `EBADF` (a bad handle is a real bug,
|
|
71
|
+
* never evidence of an unsupported platform).
|
|
72
|
+
*
|
|
73
|
+
* `platform` defaults to the running platform; tests pass explicit values
|
|
74
|
+
* to cover the matrix deterministically on any OS.
|
|
75
|
+
*/
|
|
76
|
+
export const isUnsupportedDirSync = (e, platform = process.platform) => {
|
|
77
|
+
const code = errnoOf(e);
|
|
78
|
+
if (code === 'ENOSYS' || code === 'ENOTSUP')
|
|
79
|
+
return true;
|
|
80
|
+
if (platform === 'win32' && (code === 'EINVAL' || code === 'EPERM' || code === 'EISDIR'))
|
|
81
|
+
return true;
|
|
82
|
+
return false;
|
|
83
|
+
};
|
|
84
|
+
// Thrown by `durableWrite` when the temp file was published (rename
|
|
85
|
+
// attempted) but the post-rename barrier did not certify. At that point
|
|
86
|
+
// the target may or may not hold the new bytes, so prior durable
|
|
87
|
+
// knowledge for the key is invalid. Carries the original failure.
|
|
88
|
+
class BarrierUncertainty extends Error {
|
|
89
|
+
constructor(detail) {
|
|
90
|
+
super('use-bridge-store: post-rename barrier uncertified; prior durable knowledge invalidated');
|
|
91
|
+
this.name = 'BarrierUncertainty';
|
|
92
|
+
this.detail = detail;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const defaultSyncDir = async (dir) => {
|
|
96
|
+
let handle;
|
|
97
|
+
try {
|
|
98
|
+
handle = await open(dir, 'r');
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// A store folder that cannot even be opened is not a platform
|
|
102
|
+
// quirk, except where the platform cannot open directory handles
|
|
103
|
+
// at all (classified above).
|
|
104
|
+
if (!isUnsupportedDirSync(e))
|
|
105
|
+
throw e;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await handle.sync();
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
if (!isUnsupportedDirSync(e))
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await handle.close().catch(() => { });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
// Exclusive temp creation with a restrictive mode for sensitive auth
|
|
120
|
+
// bytes. `0o600` regardless of umask: replacement renames this inode over
|
|
121
|
+
// the target, so an existing `0600` file can never be widened (a previously
|
|
122
|
+
// wider file narrows to `0600`, the safe direction for session secrets;
|
|
123
|
+
// POSIX only — Windows ignores mode bits).
|
|
124
|
+
const realOpenTmp = async (tmpPath) => open(tmpPath, 'wx', 0o600);
|
|
125
|
+
const defaultWriteTmp = async (tmpPath, value, openTmp) => {
|
|
126
|
+
// Exclusive create: never truncate a temp file this operation did
|
|
127
|
+
// not create. Temp names are unique per attempt (see durableWrite),
|
|
128
|
+
// so EEXIST is not expected — but if it ever happens the caller
|
|
129
|
+
// retries with a fresh name instead of touching foreign bytes.
|
|
130
|
+
const handle = await openTmp(tmpPath);
|
|
131
|
+
try {
|
|
132
|
+
await handle.writeFile(value);
|
|
133
|
+
await handle.sync();
|
|
134
|
+
await handle.close();
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
// Owned (we just created it exclusively): close best-effort and
|
|
138
|
+
// remove our own temp, never anyone else's. Cleanup failures
|
|
139
|
+
// cannot mask the original error — it always propagates.
|
|
140
|
+
await handle.close().catch(() => { });
|
|
141
|
+
await unlink(tmpPath).catch(() => { });
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const defaultPublishTmp = async (tmpPath, finalPath) => {
|
|
146
|
+
await rename(tmpPath, finalPath);
|
|
147
|
+
};
|
|
148
|
+
let tmpSeq = 0;
|
|
47
149
|
/**
|
|
48
150
|
* Creates a file-based store for the WASM bridge.
|
|
49
151
|
*
|
|
50
152
|
* Each (store, key) pair maps to a file: `<folder>/<store>-<key>.bin`
|
|
51
153
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
154
|
+
* Durability model:
|
|
155
|
+
* - Caller buffers are copied synchronously at admission (`set`/`setMany`
|
|
156
|
+
* copy before queueing), so mutating a buffer after the call — even
|
|
157
|
+
* before awaiting it — can never change what gets persisted.
|
|
158
|
+
* - Critical stores write through `durableWrite` before `set`/`setMany`
|
|
159
|
+
* resolve. The in-memory map only records bytes AFTER the full barrier
|
|
160
|
+
* (write + fsync + atomic rename + directory sync) succeeds, so an
|
|
161
|
+
* identical retry following a failure is never skipped and always
|
|
162
|
+
* re-attempts the write.
|
|
163
|
+
* - If the post-rename barrier fails, the key is marked uncertain: prior
|
|
164
|
+
* durable knowledge is discarded, reads serve best-available bytes
|
|
165
|
+
* without re-certifying them, and no identical set is skipped until a
|
|
166
|
+
* later operation completes the full barrier for that key.
|
|
167
|
+
* - Non-critical stores are debounced (50ms coalescing) and readable
|
|
168
|
+
* immediately (read-your-write), but such reads are NOT durable until
|
|
169
|
+
* `flush()` succeeds. A failed flush keeps the pending entry and throws,
|
|
170
|
+
* so the next `flush()` retries the same bytes.
|
|
171
|
+
* - `flush()` first waits for every operation admitted before it
|
|
172
|
+
* (barrier), then drains the pending writes those operations produced.
|
|
173
|
+
* Failures observed by the barrier propagate — a failed admitted write
|
|
174
|
+
* fails the flush — but only operations outstanding during that flush
|
|
175
|
+
* are reported, so history never poisons later flushes. A drain pass
|
|
176
|
+
* with any failure stops at that pass and leaves the failed entries
|
|
177
|
+
* for the next explicit flush. `flush()` never reports quiescence
|
|
178
|
+
* while prior admitted work is still running.
|
|
179
|
+
* - All operations on one key (set, delete, flush, concurrent batches) run
|
|
180
|
+
* through a per-key chain, so a stale failure can never erase newer
|
|
181
|
+
* state and an in-flight write can never resurrect a deleted key.
|
|
182
|
+
* - A failed delete restores the preceding pending/durable state, so an
|
|
183
|
+
* acknowledged value stays readable and flushable; only a successful
|
|
184
|
+
* delete (unlink + directory barrier) clears it. A delete retried while
|
|
185
|
+
* the key is uncertain re-runs the directory barrier instead of
|
|
186
|
+
* swallowing the uncertainty as idempotent absence — except that a
|
|
187
|
+
* directory removed externally surfaces ENOENT rather than success.
|
|
188
|
+
* - Every byte array handed back to callers is a copy.
|
|
54
189
|
*
|
|
55
190
|
* @param folder Directory to store bridge state files
|
|
191
|
+
* @param options Optional per-store file-I/O steps. Test/fault-injection
|
|
192
|
+
* seam only: the default implementation is the sole provider of the
|
|
193
|
+
* durability contract above.
|
|
56
194
|
*/
|
|
57
|
-
export async function useBridgeStore(folder) {
|
|
195
|
+
export async function useBridgeStore(folder, options) {
|
|
58
196
|
await mkdir(folder, { recursive: true });
|
|
59
|
-
|
|
197
|
+
const openTmp = options?.io?.openTmp ?? realOpenTmp;
|
|
198
|
+
const io = {
|
|
199
|
+
writeTmp: options?.io?.writeTmp ?? ((tmpPath, value) => defaultWriteTmp(tmpPath, value, openTmp)),
|
|
200
|
+
publishTmp: options?.io?.publishTmp ?? defaultPublishTmp,
|
|
201
|
+
syncDir: options?.io?.syncDir ?? defaultSyncDir
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Durable file replacement: write + fsync an exclusively-created temp
|
|
205
|
+
* file in the same directory, then atomically rename it over the
|
|
206
|
+
* target and sync the directory. The temp uses a short fixed-shape
|
|
207
|
+
* basename (independent of key length, so long-but-valid final names
|
|
208
|
+
* still fit NAME_MAX) created with mode `0600` (POSIX; Windows ignores
|
|
209
|
+
* mode bits), so replacing a hardened auth file never widens its
|
|
210
|
+
* permissions. The previous file (or its absence) is untouched until the rename succeeds, so a crash
|
|
211
|
+
* or a failed write can never leave a torn target behind — readers
|
|
212
|
+
* always see the old intact file or the new intact file. Cleanup only
|
|
213
|
+
* ever removes a temp this operation created; a temp that already
|
|
214
|
+
* existed is left alone.
|
|
215
|
+
*
|
|
216
|
+
* Guarantees are scoped honestly: the rename is atomic against a dead
|
|
217
|
+
* process, and the file + directory syncs raise the bar toward
|
|
218
|
+
* power-loss durability on platforms that honor them. No file-level
|
|
219
|
+
* test can prove power-loss survival — that would need a physical
|
|
220
|
+
* harness — so tests assert the checkable half (failure atomicity,
|
|
221
|
+
* retryability) and the code does not claim more.
|
|
222
|
+
*
|
|
223
|
+
* Every error propagates, including ENOENT: resolving a write whose
|
|
224
|
+
* bytes never reached disk would let the caller believe state is
|
|
225
|
+
* durable. A failure at or after the rename throws BarrierUncertainty
|
|
226
|
+
* so the caller invalidates prior durable knowledge for the key.
|
|
227
|
+
*/
|
|
228
|
+
const durableWrite = async (finalPath, value) => {
|
|
229
|
+
const MAX_TMP_ATTEMPTS = 5;
|
|
230
|
+
for (let attempt = 0; attempt < MAX_TMP_ATTEMPTS; attempt++) {
|
|
231
|
+
// Short fixed-shape basename in the same directory (rename stays
|
|
232
|
+
// atomic): independent of the target key length so keys whose
|
|
233
|
+
// valid final names approach NAME_MAX still fit. Uniqueness from
|
|
234
|
+
// pid + sequence + randomness; the final stored name is untouched.
|
|
235
|
+
const tmpPath = join(dirname(finalPath), `.bstore-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}-${randomBytes(4).toString('hex')}.tmp`);
|
|
236
|
+
try {
|
|
237
|
+
await io.writeTmp(tmpPath, value);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
// EEXIST means a foreign temp owns this name (we create
|
|
241
|
+
// exclusively and never overwrite): retry with a fresh name
|
|
242
|
+
// instead of deleting or reusing it. Any earlier failure
|
|
243
|
+
// left the target untouched; `writeTmp` already cleaned up
|
|
244
|
+
// its own temp.
|
|
245
|
+
if (errnoOf(e) === 'EEXIST')
|
|
246
|
+
continue;
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await io.publishTmp(tmpPath, finalPath);
|
|
251
|
+
await io.syncDir(dirname(finalPath));
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
// Our own temp failed to publish or certify: remove only our
|
|
255
|
+
// temp. If the rename itself succeeded, the temp is already
|
|
256
|
+
// gone and this unlink is a tolerated no-op — but the
|
|
257
|
+
// barrier is uncertified either way.
|
|
258
|
+
await unlink(tmpPath).catch(() => { });
|
|
259
|
+
throw new BarrierUncertainty(e);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`use-bridge-store durableWrite could not claim a unique temp name for ${finalPath}`);
|
|
264
|
+
};
|
|
265
|
+
// Last bytes known to have passed the FULL barrier per key (written,
|
|
266
|
+
// flushed, or read from disk outside uncertainty). Private copies only
|
|
267
|
+
// — never a caller-owned reference. Bounded by LRU eviction; eviction
|
|
268
|
+
// only drops knowledge, disk stays canonical.
|
|
60
269
|
const MAX_CACHE_ENTRIES = 5000;
|
|
61
|
-
const
|
|
62
|
-
const
|
|
270
|
+
const durable = new Map();
|
|
271
|
+
const rememberDurable = (key, value) => {
|
|
63
272
|
// LRU: delete + re-insert moves to end of insertion order
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
cache.delete(first);
|
|
273
|
+
durable.delete(key);
|
|
274
|
+
durable.set(key, value);
|
|
275
|
+
if (durable.size > MAX_CACHE_ENTRIES) {
|
|
276
|
+
const first = durable.keys().next().value;
|
|
277
|
+
durable.delete(first);
|
|
70
278
|
}
|
|
71
279
|
};
|
|
280
|
+
// Keys whose last mutation never completed the full barrier (post-rename
|
|
281
|
+
// or post-unlink failure). Durable knowledge is discarded; reads serve
|
|
282
|
+
// best-available bytes without re-certifying them; identical sets are
|
|
283
|
+
// never skipped. Cleared only by a later operation that completes the
|
|
284
|
+
// full barrier for the key (write, flush, or certified delete).
|
|
285
|
+
const uncertain = new Set();
|
|
72
286
|
const filePath = (store, key) => join(folder, `${store}-${encodeURIComponent(key)}.bin`);
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
287
|
+
// Per-key serialization chain. Every key operation runs inside
|
|
288
|
+
// `withKeyLock` for that key; different keys never block each other.
|
|
289
|
+
// The tail swallows rejections so one failed op cannot wedge later ops
|
|
290
|
+
// for the same key. Every admitted op is also tracked in `admitted`
|
|
291
|
+
// until it settles so `flush()` can wait for work admitted before it.
|
|
292
|
+
const chains = new Map();
|
|
293
|
+
const admitted = new Set();
|
|
294
|
+
const withKeyLock = (cacheKey, fn) => {
|
|
295
|
+
const prev = chains.get(cacheKey) ?? Promise.resolve();
|
|
296
|
+
const next = prev.then(fn, fn);
|
|
297
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
298
|
+
chains.set(cacheKey, tail);
|
|
299
|
+
tail.then(() => {
|
|
300
|
+
if (chains.get(cacheKey) === tail)
|
|
301
|
+
chains.delete(cacheKey);
|
|
302
|
+
});
|
|
303
|
+
admitted.add(next);
|
|
304
|
+
next.then(() => {
|
|
305
|
+
admitted.delete(next);
|
|
306
|
+
}, () => {
|
|
307
|
+
admitted.delete(next);
|
|
308
|
+
});
|
|
309
|
+
return next;
|
|
310
|
+
};
|
|
311
|
+
// Debounced non-critical writes not yet flushed to disk.
|
|
312
|
+
const pendingWrites = new Map();
|
|
313
|
+
const WRITE_DELAY_MS = 50;
|
|
314
|
+
const armTimer = (cacheKey, entry) => {
|
|
315
|
+
if (entry.timer)
|
|
316
|
+
clearTimeout(entry.timer);
|
|
317
|
+
entry.timer = setTimeout(() => {
|
|
318
|
+
void flushOne(cacheKey).catch(() => {
|
|
319
|
+
// No caller to report to; the entry stays pending and the
|
|
320
|
+
// next explicit flush() retries it and surfaces the error.
|
|
321
|
+
});
|
|
322
|
+
}, WRITE_DELAY_MS);
|
|
323
|
+
entry.timer.unref(); // Don't keep the process alive for debounced writes
|
|
324
|
+
};
|
|
325
|
+
// Full-barrier write shared by critical `set` and the flush drain.
|
|
326
|
+
// Certifies the key on success; on post-rename failure discards durable
|
|
327
|
+
// knowledge, marks uncertainty, and rethrows the original error.
|
|
328
|
+
const certifyWrite = async (cacheKey, path, value) => {
|
|
76
329
|
try {
|
|
77
|
-
await
|
|
330
|
+
await durableWrite(path, value);
|
|
78
331
|
}
|
|
79
332
|
catch (e) {
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
333
|
+
if (e instanceof BarrierUncertainty) {
|
|
334
|
+
durable.delete(cacheKey);
|
|
335
|
+
uncertain.add(cacheKey);
|
|
336
|
+
throw e.detail;
|
|
337
|
+
}
|
|
338
|
+
throw e;
|
|
83
339
|
}
|
|
340
|
+
uncertain.delete(cacheKey);
|
|
341
|
+
rememberDurable(cacheKey, copyBytes(value));
|
|
84
342
|
};
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
343
|
+
// Flush one pending key. Runs under the key lock (not tracked in
|
|
344
|
+
// `admitted`: the drain loop drives it, so tracking it would make
|
|
345
|
+
// `flush()` wait on itself). On failure the entry is RETAINED (timer
|
|
346
|
+
// cleared) so an explicit `flush()` retries the same bytes, and the
|
|
347
|
+
// error propagates to that caller.
|
|
348
|
+
const flushOne = (cacheKey) => withKeyLockInternal(cacheKey, async () => {
|
|
89
349
|
const pending = pendingWrites.get(cacheKey);
|
|
90
350
|
if (!pending)
|
|
91
351
|
return;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
await writeFile(pending.path, pending.value);
|
|
352
|
+
if (pending.timer) {
|
|
353
|
+
clearTimeout(pending.timer);
|
|
354
|
+
pending.timer = undefined;
|
|
96
355
|
}
|
|
97
|
-
|
|
98
|
-
|
|
356
|
+
await certifyWrite(cacheKey, pending.path, pending.value);
|
|
357
|
+
if (pendingWrites.get(cacheKey) === pending)
|
|
358
|
+
pendingWrites.delete(cacheKey);
|
|
359
|
+
});
|
|
360
|
+
// Same as `withKeyLock` but invisible to the flush admission barrier.
|
|
361
|
+
// Only the flush drain loop may use this; key operations use
|
|
362
|
+
// `withKeyLock` so `flush()` observes them.
|
|
363
|
+
const withKeyLockInternal = (cacheKey, fn) => {
|
|
364
|
+
const prev = chains.get(cacheKey) ?? Promise.resolve();
|
|
365
|
+
const next = prev.then(fn, fn);
|
|
366
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
367
|
+
chains.set(cacheKey, tail);
|
|
368
|
+
tail.then(() => {
|
|
369
|
+
if (chains.get(cacheKey) === tail)
|
|
370
|
+
chains.delete(cacheKey);
|
|
371
|
+
});
|
|
372
|
+
return next;
|
|
373
|
+
};
|
|
374
|
+
const FLUSH_MAX_PASSES = 32;
|
|
375
|
+
const flushAll = async () => {
|
|
376
|
+
// Each pass first waits for every operation admitted so far
|
|
377
|
+
// (barrier), then drains the pending writes those operations
|
|
378
|
+
// produced. A `set()` queued just before `flush()` is therefore
|
|
379
|
+
// observed even if it had not reached `pendingWrites` yet, and an
|
|
380
|
+
// already-running critical write completes before quiescence is
|
|
381
|
+
// declared. `flushOne` internals are not admitted, so the barrier
|
|
382
|
+
// never waits on the drain loop itself (no self-deadlock).
|
|
383
|
+
// Failures observed by the barrier propagate: a failed admitted
|
|
384
|
+
// write fails the flush. Only operations outstanding during this
|
|
385
|
+
// flush are reported — settled history never poisons later flushes.
|
|
386
|
+
// After a drain pass with any failure, stop and leave the failed
|
|
387
|
+
// entries for a subsequent explicit flush: retrying them inside the
|
|
388
|
+
// same call would resolve-or-reject on a stale error while hiding
|
|
389
|
+
// whether the retry itself persisted. A fully successful pass loops,
|
|
390
|
+
// since new work may have landed during the drain. The pass cap only
|
|
391
|
+
// guards a caller emitting writes in a tight loop —
|
|
392
|
+
// `Socket/index.ts.end()` waits on this and must return.
|
|
393
|
+
const errors = [];
|
|
394
|
+
for (let i = 0; i < FLUSH_MAX_PASSES; i++) {
|
|
395
|
+
const outstanding = [...admitted];
|
|
396
|
+
if (outstanding.length > 0) {
|
|
397
|
+
const settled = await Promise.allSettled(outstanding);
|
|
398
|
+
for (const result of settled) {
|
|
399
|
+
if (result.status === 'rejected')
|
|
400
|
+
errors.push(result.reason);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (pendingWrites.size === 0) {
|
|
404
|
+
if (admitted.size === 0)
|
|
405
|
+
break;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const keys = [...pendingWrites.keys()];
|
|
409
|
+
let failed = false;
|
|
410
|
+
await Promise.all(keys.map(key => flushOne(key).then(() => { }, e => {
|
|
411
|
+
failed = true;
|
|
412
|
+
errors.push(e);
|
|
413
|
+
})));
|
|
414
|
+
if (failed)
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
if (errors.length > 0)
|
|
418
|
+
throw errors[0];
|
|
419
|
+
if (pendingWrites.size > 0 || admitted.size > 0) {
|
|
420
|
+
throw new Error(`use-bridge-store flushAll did not quiesce after ${FLUSH_MAX_PASSES} passes (${pendingWrites.size} pending writes, ${admitted.size} in-flight operations remain)`);
|
|
99
421
|
}
|
|
100
422
|
};
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
423
|
+
// Write path shared by `set` and `setMany`. `incoming` is already a
|
|
424
|
+
// private admission-time copy. Must run under the key lock — callers
|
|
425
|
+
// wrap it via `withKeyLock`.
|
|
426
|
+
const doSetLocked = async (store, key, cacheKey, incoming) => {
|
|
427
|
+
// Skip only when the identical bytes are already barrier-certified
|
|
428
|
+
// or already queued. Uncertainty discards certification, so a
|
|
429
|
+
// retry after an uncertified barrier always re-attempts instead of
|
|
430
|
+
// resolving without persisting.
|
|
431
|
+
const pending = pendingWrites.get(cacheKey);
|
|
432
|
+
if (pending) {
|
|
433
|
+
if (bytesEqual(pending.value, incoming))
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
const known = durable.get(cacheKey);
|
|
438
|
+
if (known && bytesEqual(known, incoming))
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (CRITICAL_STORES.has(store)) {
|
|
442
|
+
if (pending) {
|
|
443
|
+
if (pending.timer)
|
|
444
|
+
clearTimeout(pending.timer);
|
|
113
445
|
pendingWrites.delete(cacheKey);
|
|
114
446
|
}
|
|
447
|
+
// Propagate every failure (ENOSPC/EACCES/EIO/ENOTDIR/ENOENT) —
|
|
448
|
+
// losing a critical Signal write silently corrupts next decrypt.
|
|
449
|
+
await certifyWrite(cacheKey, filePath(store, key), incoming);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Non-critical writes: coalesce rapid writes to the same key
|
|
453
|
+
if (pending?.timer)
|
|
454
|
+
clearTimeout(pending.timer);
|
|
455
|
+
const entry = {
|
|
456
|
+
store,
|
|
457
|
+
key,
|
|
458
|
+
path: filePath(store, key),
|
|
459
|
+
value: incoming,
|
|
460
|
+
timer: undefined
|
|
461
|
+
};
|
|
462
|
+
armTimer(cacheKey, entry);
|
|
463
|
+
pendingWrites.set(cacheKey, entry);
|
|
464
|
+
};
|
|
465
|
+
const doSet = (store, key, value) => {
|
|
466
|
+
// Admission-time copy, synchronous with the call: a caller mutating
|
|
467
|
+
// its buffer immediately after (even before awaiting) cannot change
|
|
468
|
+
// what this operation persists. `Uint8Array.from` copies Buffer
|
|
469
|
+
// inputs too — `Buffer.slice` would only create a shared view.
|
|
470
|
+
const incoming = copyBytes(value);
|
|
471
|
+
const cacheKey = `${store}\0${key}`;
|
|
472
|
+
return withKeyLock(cacheKey, () => doSetLocked(store, key, cacheKey, incoming));
|
|
473
|
+
};
|
|
474
|
+
// Read path shared by `get` and `getMany`. Runs under the key lock so
|
|
475
|
+
// a concurrent delete cannot leave stale bytes resurrected in `durable`.
|
|
476
|
+
// Returns a copy — callers can never mutate the cache or in-flight data.
|
|
477
|
+
// A pending (debounced, not yet durable) value satisfies read-your-write
|
|
478
|
+
// immediately; it is exposed as pending, and `flush()` is the call that
|
|
479
|
+
// makes it durable. Under uncertainty, disk bytes are served
|
|
480
|
+
// best-available WITHOUT re-certifying them: caching them as durable
|
|
481
|
+
// would let a later identical set skip its still-required barrier.
|
|
482
|
+
const doGetLocked = async (store, key, cacheKey) => {
|
|
483
|
+
const pending = pendingWrites.get(cacheKey);
|
|
484
|
+
if (pending)
|
|
485
|
+
return copyBytes(pending.value);
|
|
486
|
+
const keyUncertain = uncertain.has(cacheKey);
|
|
487
|
+
if (!keyUncertain) {
|
|
488
|
+
const known = durable.get(cacheKey);
|
|
489
|
+
if (known)
|
|
490
|
+
return copyBytes(known);
|
|
491
|
+
}
|
|
492
|
+
let data;
|
|
493
|
+
try {
|
|
494
|
+
data = await readFile(filePath(store, key));
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
// Absent key is a legitimate null. Any other read failure
|
|
498
|
+
// (EACCES/EIO/ENOTDIR) must NOT masquerade as "no value", or the
|
|
499
|
+
// core would treat persisted state as gone.
|
|
500
|
+
if (isEnoent(e))
|
|
501
|
+
return null;
|
|
502
|
+
throw e;
|
|
503
|
+
}
|
|
504
|
+
const arr = copyBytes(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
505
|
+
if (!keyUncertain)
|
|
506
|
+
rememberDurable(cacheKey, copyBytes(arr));
|
|
507
|
+
return arr;
|
|
508
|
+
};
|
|
509
|
+
const doGet = (store, key) => {
|
|
510
|
+
const cacheKey = `${store}\0${key}`;
|
|
511
|
+
return withKeyLock(cacheKey, () => doGetLocked(store, key, cacheKey));
|
|
512
|
+
};
|
|
513
|
+
// Delete path shared by `delete`, `deleteMany` and `deletePrefix`.
|
|
514
|
+
// Runs under the key lock. The preceding pending/durable state is only
|
|
515
|
+
// cleared once the unlink succeeds; if the unlink fails with a real
|
|
516
|
+
// error, that state is restored (timer re-armed) so an acknowledged
|
|
517
|
+
// value stays readable and flushable, and the error propagates. A
|
|
518
|
+
// successful unlink completes the directory barrier; if that barrier
|
|
519
|
+
// fails, the key is marked uncertain. A delete retried while uncertain
|
|
520
|
+
// re-runs the directory barrier instead of mistaking ENOENT for
|
|
521
|
+
// certified absence — but a directory removed externally still surfaces
|
|
522
|
+
// ENOENT rather than success, by explicit policy. Certain absence stays
|
|
523
|
+
// idempotent.
|
|
524
|
+
const doDeleteOne = (store, key) => {
|
|
525
|
+
const cacheKey = `${store}\0${key}`;
|
|
526
|
+
return withKeyLock(cacheKey, async () => {
|
|
527
|
+
const prevPending = pendingWrites.get(cacheKey);
|
|
528
|
+
const prevDurable = durable.get(cacheKey);
|
|
529
|
+
const wasUncertain = uncertain.has(cacheKey);
|
|
530
|
+
if (prevPending?.timer)
|
|
531
|
+
clearTimeout(prevPending.timer);
|
|
532
|
+
pendingWrites.delete(cacheKey);
|
|
533
|
+
durable.delete(cacheKey);
|
|
534
|
+
// Reinstate the state captured above after any delete failure
|
|
535
|
+
// that leaves the key's fate undecided, so an acknowledged
|
|
536
|
+
// value stays readable and flushable. Not used when absence is
|
|
537
|
+
// certified (the delete won) or when the unlink itself succeeded
|
|
538
|
+
// (the file is genuinely gone; only the barrier is uncertain).
|
|
539
|
+
const restoreDeleteState = () => {
|
|
540
|
+
if (prevDurable)
|
|
541
|
+
rememberDurable(cacheKey, prevDurable);
|
|
542
|
+
if (prevPending) {
|
|
543
|
+
armTimer(cacheKey, prevPending);
|
|
544
|
+
pendingWrites.set(cacheKey, prevPending);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
115
547
|
try {
|
|
116
548
|
await unlink(filePath(store, key));
|
|
117
549
|
}
|
|
118
|
-
catch {
|
|
119
|
-
|
|
550
|
+
catch (e) {
|
|
551
|
+
if (isEnoent(e) && !wasUncertain)
|
|
552
|
+
return;
|
|
553
|
+
if (isEnoent(e)) {
|
|
554
|
+
// Absent on disk, but a prior barrier was never
|
|
555
|
+
// certified: certify the absence now instead of
|
|
556
|
+
// swallowing the uncertainty.
|
|
557
|
+
try {
|
|
558
|
+
await io.syncDir(folder);
|
|
559
|
+
}
|
|
560
|
+
catch (syncError) {
|
|
561
|
+
restoreDeleteState();
|
|
562
|
+
throw syncError;
|
|
563
|
+
}
|
|
564
|
+
uncertain.delete(cacheKey);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
restoreDeleteState();
|
|
568
|
+
throw e;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
await io.syncDir(folder);
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
uncertain.add(cacheKey);
|
|
575
|
+
throw e;
|
|
120
576
|
}
|
|
121
|
-
|
|
577
|
+
uncertain.delete(cacheKey);
|
|
578
|
+
});
|
|
122
579
|
};
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const keys = [...pendingWrites.keys()];
|
|
133
|
-
await Promise.all(keys.map(flushWrite));
|
|
134
|
-
}
|
|
135
|
-
if (pendingWrites.size > 0) {
|
|
136
|
-
throw new Error(`use-bridge-store flushAll did not quiesce after ${FLUSH_MAX_PASSES} passes (${pendingWrites.size} pending writes remain)`);
|
|
137
|
-
}
|
|
580
|
+
// Delete many keys concurrently (shared by `deleteMany` and
|
|
581
|
+
// `deletePrefix`). Defined as a closure rather than a method so callers
|
|
582
|
+
// don't depend on `this` — the bridge invokes every store callback with
|
|
583
|
+
// `this = null`. Best-effort across keys: every key is attempted even
|
|
584
|
+
// when a sibling fails, and the first error propagates.
|
|
585
|
+
const doDeleteMany = async (store, keys) => {
|
|
586
|
+
if (keys.length === 0)
|
|
587
|
+
return;
|
|
588
|
+
await Promise.all(keys.map(key => doDeleteOne(store, key)));
|
|
138
589
|
};
|
|
139
590
|
// Enumerate live keys in a namespace (shared by `listKeys` and
|
|
140
591
|
// `deletePrefix`). Closure, not a method, so it never depends on `this`.
|
|
141
592
|
// Files are `<store>-<encodeURIComponent(key)>.bin`; store names never
|
|
142
593
|
// contain a hyphen, so split on the FIRST hyphen and decode the remainder.
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
594
|
+
// A flush is awaited first so admitted and debounced writes are durable
|
|
595
|
+
// before the readdir; its failure propagates rather than enumerating a
|
|
596
|
+
// stale view. Pending deletes both cancel their `pendingWrites` entry
|
|
597
|
+
// AND unlink immediately, so they never appear here.
|
|
147
598
|
const doListKeys = async (store, prefix) => {
|
|
148
599
|
await flushAll();
|
|
149
600
|
const filePrefix = `${store}-`;
|
|
@@ -190,150 +641,39 @@ export async function useBridgeStore(folder) {
|
|
|
190
641
|
return [...found];
|
|
191
642
|
};
|
|
192
643
|
return {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const cached = cache.get(cacheKey);
|
|
197
|
-
if (cached)
|
|
198
|
-
return cached;
|
|
199
|
-
const pending = pendingWrites.get(cacheKey);
|
|
200
|
-
if (pending)
|
|
201
|
-
return pending.value;
|
|
202
|
-
try {
|
|
203
|
-
const data = await readFile(filePath(store, key));
|
|
204
|
-
const arr = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
205
|
-
touchCache(cacheKey, arr);
|
|
206
|
-
return arr;
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
},
|
|
212
|
-
async set(store, key, value) {
|
|
213
|
-
const cacheKey = `${store}\0${key}`;
|
|
214
|
-
// Skip write if value is identical to cached version
|
|
215
|
-
const prev = cache.get(cacheKey);
|
|
216
|
-
if (prev && Buffer.from(prev).equals(Buffer.from(value))) {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
touchCache(cacheKey, value);
|
|
220
|
-
if (CRITICAL_STORES.has(store)) {
|
|
221
|
-
const existing = pendingWrites.get(cacheKey);
|
|
222
|
-
if (existing) {
|
|
223
|
-
clearTimeout(existing.timer);
|
|
224
|
-
pendingWrites.delete(cacheKey);
|
|
225
|
-
}
|
|
226
|
-
// Propagate real failures (ENOSPC/EACCES/…) — losing a critical
|
|
227
|
-
// Signal write silently corrupts the next decrypt.
|
|
228
|
-
await writeCritical(store, key, value);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
// Non-critical writes: coalesce rapid writes to the same key
|
|
232
|
-
const existing = pendingWrites.get(cacheKey);
|
|
233
|
-
if (existing) {
|
|
234
|
-
clearTimeout(existing.timer);
|
|
235
|
-
}
|
|
236
|
-
const path = filePath(store, key);
|
|
237
|
-
const timer = setTimeout(() => void flushWrite(cacheKey), WRITE_DELAY_MS);
|
|
238
|
-
timer.unref(); // Don't keep the process alive for debounced writes
|
|
239
|
-
pendingWrites.set(cacheKey, { path, value, timer });
|
|
240
|
-
},
|
|
241
|
-
async delete(store, key) {
|
|
242
|
-
const cacheKey = `${store}\0${key}`;
|
|
243
|
-
cache.delete(cacheKey);
|
|
244
|
-
// Cancel pending write
|
|
245
|
-
const existing = pendingWrites.get(cacheKey);
|
|
246
|
-
if (existing) {
|
|
247
|
-
clearTimeout(existing.timer);
|
|
248
|
-
pendingWrites.delete(cacheKey);
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
await unlink(filePath(store, key));
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
// ignore if file doesn't exist
|
|
255
|
-
}
|
|
256
|
-
},
|
|
644
|
+
get: doGet,
|
|
645
|
+
set: doSet,
|
|
646
|
+
delete: doDeleteOne,
|
|
257
647
|
// Batched variant of `set`. The bridge calls this (when present) to
|
|
258
648
|
// persist a burst of entries in a single FFI crossing instead of N
|
|
259
|
-
// round-trips (e.g. ~20k messageSecrets from a history sync).
|
|
260
|
-
// semantics are identical to `set` —
|
|
261
|
-
//
|
|
649
|
+
// round-trips (e.g. ~20k messageSecrets from a history sync).
|
|
650
|
+
// Per-entry semantics are identical to `set` — admission copies are
|
|
651
|
+
// taken synchronously for every entry, in order. Best-effort across
|
|
652
|
+
// keys per the bridge contract: every entry is attempted even when
|
|
653
|
+
// a sibling fails (writes are idempotent by key, so the core can
|
|
654
|
+
// retry the batch), and the first error propagates. No multi-key
|
|
655
|
+
// transaction is attempted — the filesystem does not provide one.
|
|
262
656
|
async setMany(store, entries) {
|
|
263
657
|
// Empty batch is a valid no-op.
|
|
264
658
|
if (entries.length === 0)
|
|
265
659
|
return;
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
for (const [key, value] of entries) {
|
|
271
|
-
const cacheKey = `${store}\0${key}`;
|
|
272
|
-
// Skip write if value is identical to cached version.
|
|
273
|
-
// Buffer.compare accepts Uint8Array directly (no copy), unlike
|
|
274
|
-
// Buffer.from(prev).equals(Buffer.from(value)) which copied both.
|
|
275
|
-
const prev = cache.get(cacheKey);
|
|
276
|
-
if (prev && prev.length === value.length && Buffer.compare(prev, value) === 0) {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
touchCache(cacheKey, value);
|
|
280
|
-
if (critical) {
|
|
281
|
-
// Cancel any pending debounced write for this key first,
|
|
282
|
-
// exactly like `set` does, then write immediately.
|
|
283
|
-
const existing = pendingWrites.get(cacheKey);
|
|
284
|
-
if (existing) {
|
|
285
|
-
clearTimeout(existing.timer);
|
|
286
|
-
pendingWrites.delete(cacheKey);
|
|
287
|
-
}
|
|
288
|
-
// Propagate real failures: if a critical write in the batch
|
|
289
|
-
// fails, setMany rejects, and the Rust core skips the
|
|
290
|
-
// follow-up self-index rewrite (so the index can't claim a
|
|
291
|
-
// value exists that was never persisted).
|
|
292
|
-
criticalWrites.push(writeCritical(store, key, value));
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
// Non-critical writes: coalesce rapid writes to the same key.
|
|
296
|
-
// The debounce already coalesces a burst, so scheduling each
|
|
297
|
-
// entry is fine — no immediate flush needed.
|
|
298
|
-
const existing = pendingWrites.get(cacheKey);
|
|
299
|
-
if (existing) {
|
|
300
|
-
clearTimeout(existing.timer);
|
|
301
|
-
}
|
|
302
|
-
const path = filePath(store, key);
|
|
303
|
-
const timer = setTimeout(() => void flushWrite(cacheKey), WRITE_DELAY_MS);
|
|
304
|
-
timer.unref(); // Don't keep the process alive for debounced writes
|
|
305
|
-
pendingWrites.set(cacheKey, { path, value, timer });
|
|
306
|
-
}
|
|
307
|
-
// Run all critical writes concurrently.
|
|
308
|
-
if (criticalWrites.length > 0) {
|
|
309
|
-
await Promise.all(criticalWrites);
|
|
310
|
-
}
|
|
660
|
+
// Schedule every entry synchronously so same-key duplicates chain
|
|
661
|
+
// in batch order; per-key locks serialize against concurrent
|
|
662
|
+
// sets, deletes and flushes.
|
|
663
|
+
await Promise.all(entries.map(([key, value]) => doSet(store, key, value)));
|
|
311
664
|
},
|
|
312
665
|
// Batched variant of `delete`. Per-key semantics are identical to
|
|
313
666
|
// `delete`; unlinks run concurrently via Promise.all.
|
|
314
667
|
deleteMany: doDeleteMany,
|
|
315
668
|
// Read many keys at once. Cache-aside per key (like `get`), so a hit
|
|
316
|
-
// never touches disk; misses read the file. Missing keys are omitted
|
|
669
|
+
// never touches disk; misses read the file. Missing keys are omitted;
|
|
670
|
+
// real read failures propagate rather than silently dropping keys.
|
|
317
671
|
async getMany(store, keys) {
|
|
318
672
|
if (keys.length === 0)
|
|
319
673
|
return [];
|
|
320
674
|
const results = await Promise.all(keys.map(async (key) => {
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
if (cached)
|
|
324
|
-
return [key, cached];
|
|
325
|
-
const pending = pendingWrites.get(cacheKey);
|
|
326
|
-
if (pending)
|
|
327
|
-
return [key, pending.value];
|
|
328
|
-
try {
|
|
329
|
-
const data = await readFile(filePath(store, key));
|
|
330
|
-
const arr = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
331
|
-
touchCache(cacheKey, arr);
|
|
332
|
-
return [key, arr];
|
|
333
|
-
}
|
|
334
|
-
catch {
|
|
335
|
-
return null;
|
|
336
|
-
}
|
|
675
|
+
const value = await doGet(store, key);
|
|
676
|
+
return value === null ? null : [key, value];
|
|
337
677
|
}));
|
|
338
678
|
return results.filter((r) => r !== null);
|
|
339
679
|
},
|