space-data-module-sdk 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,848 @@
1
+ /**
2
+ * SharedArrayBuffer I/O request ring: the multi-slot extension of
3
+ * sabHostcallChannel.js for FlatSQL's seven `flatsql_io_*` imports
4
+ * (docs/architecture/flatsql-partition-store.md §5.5, A7, A36, A38).
5
+ *
6
+ * sabHostcallChannel.js serves ONE blocking guest against an async host, one
7
+ * request at a time, over postMessage. This channel serves MANY guest threads
8
+ * at once against one I/O worker, which holds every OPFS handle and never
9
+ * blocks:
10
+ *
11
+ * guest thread (pool worker) I/O worker (flatsqlIoServer.js)
12
+ * ────────────────────────── ──────────────────────────────
13
+ * claim a slot once (CAS FREE -> IDLE)
14
+ * write {op, handle, ptr, len, offset, flags}
15
+ * state <- PENDING; doorbell += 1; notify ───▶ waitAsync(doorbell) or message
16
+ * scan slots: PENDING -> SERVICING
17
+ * sync ops run at once, async opens
18
+ * are awaited without blocking others
19
+ * Atomics.wait(state) in bounded slices ◀─── status; state <- DONE; notify(state)
20
+ * state <- IDLE
21
+ *
22
+ * Properties the design requires:
23
+ * - Each request owns its slot until its result is read, so a guest thread
24
+ * never queues behind another guest's request; a slow open blocks only its
25
+ * caller. Size the ring to the guest threads plus async callers.
26
+ * - No timeout and no throw (A36). A client waits in bounded slices forever;
27
+ * a dead I/O worker is handled by its supervisor, which completes pending
28
+ * slots with an error status (failPendingSabIoRequests).
29
+ * - Doorbell: `Atomics.waitAsync` where the server has it; otherwise the
30
+ * client also posts a message on a BroadcastChannel (22.3a-5). The mode is
31
+ * chosen by the server and published in the header.
32
+ * - Data moves directly between the guest's shared memory and the file when
33
+ * the instance's memory is attached to the I/O worker; otherwise through
34
+ * the slot's own data area ("scratch"). Both paths give the same bytes.
35
+ * - Revocation (A36): the supervisor marks an instance REVOKING in the
36
+ * header; the server fails that instance's requests with ACCESS, closes its
37
+ * handles, and acknowledges with REVOKED. revokeSabIoInstance() resolves
38
+ * only after the acknowledgement, so no byte is written after it returns.
39
+ */
40
+
41
+ import {
42
+ FLATSQL_IO_ERR_ACCESS,
43
+ FLATSQL_IO_ERR_GENERIC,
44
+ FLATSQL_IO_ERR_IO,
45
+ FLATSQL_IO_MAX_PATH_BYTES,
46
+ } from "./flatsqlIoContract.js";
47
+
48
+ export const SAB_IO_MAGIC = 0x4f494453; // "SDIO" little-endian
49
+ export const SAB_IO_VERSION = 1;
50
+
51
+ // ---- header words (Int32 index) ------------------------------------------
52
+ export const SAB_IO_H_MAGIC = 0;
53
+ export const SAB_IO_H_VERSION = 1;
54
+ export const SAB_IO_H_SLOT_COUNT = 2;
55
+ export const SAB_IO_H_SLOT_BYTES = 3;
56
+ export const SAB_IO_H_MAX_INSTANCES = 4;
57
+ export const SAB_IO_H_DOORBELL = 5;
58
+ export const SAB_IO_H_SERVER_STATE = 6;
59
+ export const SAB_IO_H_DOORBELL_MODE = 7;
60
+ export const SAB_IO_H_SERVER_EPOCH = 8;
61
+ export const SAB_IO_H_CLAIM_HINT = 9;
62
+ export const SAB_IO_H_SLOT_RELEASES = 10;
63
+ export const SAB_IO_H_DATA_BYTES = 11;
64
+ export const SAB_IO_H_SERVED = 12;
65
+ export const SAB_IO_H_CHANNEL_ID = 13;
66
+ const HEADER_WORDS = 64;
67
+ const HEADER_BYTES = HEADER_WORDS * 4;
68
+
69
+ export const SAB_IO_SERVER_NOT_STARTED = 0;
70
+ export const SAB_IO_SERVER_RUNNING = 1;
71
+ export const SAB_IO_SERVER_STOPPED = 2;
72
+ export const SAB_IO_SERVER_DEAD = 3;
73
+
74
+ export const SAB_IO_DOORBELL_ATOMICS = 0;
75
+ export const SAB_IO_DOORBELL_MESSAGE = 1;
76
+
77
+ // ---- instance table (one Int32 per instance id) ----------------------------
78
+ export const SAB_IO_INSTANCE_NONE = 0;
79
+ export const SAB_IO_INSTANCE_ATTACHED = 1;
80
+ export const SAB_IO_INSTANCE_REVOKING = 2;
81
+ export const SAB_IO_INSTANCE_REVOKED = 3;
82
+ /**
83
+ * Instance id of callers that are not a wasm instance (tests, the engine
84
+ * worker's own bookkeeping). It has no attached memory and is never revoked;
85
+ * its data always moves through the slot data area.
86
+ */
87
+ export const SAB_IO_SCRATCH_INSTANCE = 0x7fff;
88
+
89
+ // ---- slot words (Int32 index relative to the slot) ------------------------
90
+ export const SAB_IO_S_STATE = 0;
91
+ export const SAB_IO_S_OWNER = 1;
92
+ export const SAB_IO_S_SEQ = 2;
93
+ export const SAB_IO_S_DONE_SEQ = 3;
94
+ export const SAB_IO_S_OP = 4;
95
+ export const SAB_IO_S_INSTANCE = 5;
96
+ export const SAB_IO_S_HANDLE = 6;
97
+ export const SAB_IO_S_FLAGS = 7;
98
+ export const SAB_IO_S_PTR = 8;
99
+ export const SAB_IO_S_LEN = 9;
100
+ export const SAB_IO_S_PATH_LEN = 10;
101
+ export const SAB_IO_S_STATUS = 11;
102
+ export const SAB_IO_S_NOTIFY = 12;
103
+ export const SAB_IO_S_DATA_MODE = 13;
104
+ // Float64 index relative to the slot.
105
+ export const SAB_IO_S_F64_OFFSET = 8;
106
+ export const SAB_IO_S_F64_RESULT = 9;
107
+ const SLOT_HEADER_BYTES = 128;
108
+
109
+ export const SAB_IO_SLOT_FREE = 0;
110
+ export const SAB_IO_SLOT_IDLE = 1;
111
+ export const SAB_IO_SLOT_PENDING = 2;
112
+ export const SAB_IO_SLOT_SERVICING = 3;
113
+ export const SAB_IO_SLOT_DONE = 4;
114
+
115
+ export const SAB_IO_NOTIFY_ATOMICS = 0;
116
+ export const SAB_IO_NOTIFY_MESSAGE = 1;
117
+
118
+ /** `ptr` addresses the instance's attached memory. */
119
+ export const SAB_IO_DATA_MEMORY = 0;
120
+ /** `ptr` addresses the slot's own data area (the scratch path). */
121
+ export const SAB_IO_DATA_SLOT = 1;
122
+
123
+ // ---- operations -----------------------------------------------------------
124
+ export const SAB_IO_OP_OPEN = 1;
125
+ export const SAB_IO_OP_READ = 2;
126
+ export const SAB_IO_OP_WRITE = 3;
127
+ export const SAB_IO_OP_TRUNCATE = 4;
128
+ export const SAB_IO_OP_SYNC = 5;
129
+ export const SAB_IO_OP_SIZE = 6;
130
+ export const SAB_IO_OP_CLOSE = 7;
131
+
132
+ export const DEFAULT_SAB_IO_SLOTS = 64;
133
+ export const DEFAULT_SAB_IO_DATA_BYTES = 64 * 1024;
134
+ export const DEFAULT_SAB_IO_MAX_INSTANCES = 64;
135
+
136
+ /** Bound on one blocking wait. Waits loop forever in slices; they never time out. */
137
+ export const SAB_IO_WAIT_SLICE_MS = 250;
138
+ /** How long a blocking client polls for its answer before it sleeps. */
139
+ export const DEFAULT_SAB_IO_CLIENT_SPIN_MICROS = 20;
140
+
141
+ function nowMs() {
142
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
143
+ }
144
+
145
+ function alignUp(value, alignment) {
146
+ return Math.ceil(value / alignment) * alignment;
147
+ }
148
+
149
+ function assertSharedArrayBuffer(buffer) {
150
+ if (
151
+ typeof SharedArrayBuffer !== "function" ||
152
+ !(buffer instanceof SharedArrayBuffer)
153
+ ) {
154
+ throw new TypeError(
155
+ "The SAB I/O channel requires a SharedArrayBuffer (cross-origin isolation in browsers).",
156
+ );
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Allocate the shared buffer of one I/O channel.
162
+ *
163
+ * @param {object} [options]
164
+ * @param {number} [options.slots=64] concurrent requests; size it to at least
165
+ * the number of guest threads plus async callers using this channel.
166
+ * @param {number} [options.dataBytes=65536] per-slot data area: paths, and the
167
+ * scratch path for unattached callers. Must hold the longest path (4096).
168
+ * @param {number} [options.maxInstances=64] instance ids 0..maxInstances-1.
169
+ */
170
+ export function createSabIoChannelBuffer(options = {}) {
171
+ if (typeof SharedArrayBuffer !== "function") {
172
+ throw new Error(
173
+ "SharedArrayBuffer is unavailable; the I/O channel requires it " +
174
+ "(browsers additionally require cross-origin isolation: COOP+COEP).",
175
+ );
176
+ }
177
+ const slots = Number.isInteger(options.slots) ? options.slots : DEFAULT_SAB_IO_SLOTS;
178
+ const dataBytes = Number.isInteger(options.dataBytes)
179
+ ? options.dataBytes
180
+ : DEFAULT_SAB_IO_DATA_BYTES;
181
+ const maxInstances = Number.isInteger(options.maxInstances)
182
+ ? options.maxInstances
183
+ : DEFAULT_SAB_IO_MAX_INSTANCES;
184
+ if (slots < 1 || slots > 4096) {
185
+ throw new RangeError("slots must be an integer in 1..4096.");
186
+ }
187
+ if (dataBytes < FLATSQL_IO_MAX_PATH_BYTES || dataBytes % 8 !== 0) {
188
+ throw new RangeError(
189
+ `dataBytes must be a multiple of 8 and at least ${FLATSQL_IO_MAX_PATH_BYTES}.`,
190
+ );
191
+ }
192
+ if (maxInstances < 1 || maxInstances > SAB_IO_SCRATCH_INSTANCE) {
193
+ throw new RangeError(`maxInstances must be in 1..${SAB_IO_SCRATCH_INSTANCE}.`);
194
+ }
195
+ const slotBytes = alignUp(SLOT_HEADER_BYTES + dataBytes, 64);
196
+ const slotsOffset = alignUp(HEADER_BYTES + maxInstances * 4, 64);
197
+ const buffer = new SharedArrayBuffer(slotsOffset + slots * slotBytes);
198
+ const header = new Int32Array(buffer, 0, HEADER_WORDS);
199
+ header[SAB_IO_H_MAGIC] = SAB_IO_MAGIC;
200
+ header[SAB_IO_H_VERSION] = SAB_IO_VERSION;
201
+ header[SAB_IO_H_SLOT_COUNT] = slots;
202
+ header[SAB_IO_H_SLOT_BYTES] = slotBytes;
203
+ header[SAB_IO_H_MAX_INSTANCES] = maxInstances;
204
+ header[SAB_IO_H_DATA_BYTES] = dataBytes;
205
+ // A random channel id names the BroadcastChannels of message-mode doorbells.
206
+ header[SAB_IO_H_CHANNEL_ID] = (Math.random() * 0x7fffffff) | 0 || 1;
207
+ return buffer;
208
+ }
209
+
210
+ /** Views and geometry of a channel buffer. */
211
+ export function describeSabIoChannel(buffer) {
212
+ assertSharedArrayBuffer(buffer);
213
+ const header = new Int32Array(buffer, 0, HEADER_WORDS);
214
+ if (header[SAB_IO_H_MAGIC] !== SAB_IO_MAGIC || header[SAB_IO_H_VERSION] !== SAB_IO_VERSION) {
215
+ throw new TypeError("Not a SAB I/O channel buffer (bad magic or version).");
216
+ }
217
+ const slotCount = header[SAB_IO_H_SLOT_COUNT];
218
+ const slotBytes = header[SAB_IO_H_SLOT_BYTES];
219
+ const maxInstances = header[SAB_IO_H_MAX_INSTANCES];
220
+ const dataBytes = header[SAB_IO_H_DATA_BYTES];
221
+ const slotsOffset = alignUp(HEADER_BYTES + maxInstances * 4, 64);
222
+ const instances = new Int32Array(buffer, HEADER_BYTES, maxInstances);
223
+ const slots = [];
224
+ for (let index = 0; index < slotCount; index += 1) {
225
+ const base = slotsOffset + index * slotBytes;
226
+ slots.push({
227
+ index,
228
+ base,
229
+ i32: new Int32Array(buffer, base, SLOT_HEADER_BYTES / 4),
230
+ f64: new Float64Array(buffer, base, SLOT_HEADER_BYTES / 8),
231
+ data: new Uint8Array(buffer, base + SLOT_HEADER_BYTES, dataBytes),
232
+ });
233
+ }
234
+ return {
235
+ buffer,
236
+ header,
237
+ instances,
238
+ slots,
239
+ slotCount,
240
+ slotBytes,
241
+ dataBytes,
242
+ maxInstances,
243
+ channelId: header[SAB_IO_H_CHANNEL_ID],
244
+ };
245
+ }
246
+
247
+ /** BroadcastChannel names used by message-mode doorbells and completions. */
248
+ export function sabIoDoorbellChannelName(channelId) {
249
+ return `sdm-sab-io-doorbell-${channelId}`;
250
+ }
251
+ export function sabIoCompletionChannelName(channelId) {
252
+ return `sdm-sab-io-done-${channelId}`;
253
+ }
254
+
255
+ function ringDoorbell(layout, messenger) {
256
+ Atomics.add(layout.header, SAB_IO_H_DOORBELL, 1);
257
+ Atomics.notify(layout.header, SAB_IO_H_DOORBELL);
258
+ if (Atomics.load(layout.header, SAB_IO_H_DOORBELL_MODE) === SAB_IO_DOORBELL_MESSAGE) {
259
+ messenger()?.postMessage(0);
260
+ }
261
+ }
262
+
263
+ function lazyBroadcast(name) {
264
+ let channel = null;
265
+ return {
266
+ get() {
267
+ if (!channel && typeof BroadcastChannel === "function") {
268
+ channel = new BroadcastChannel(name);
269
+ }
270
+ return channel;
271
+ },
272
+ close() {
273
+ channel?.close();
274
+ channel = null;
275
+ },
276
+ };
277
+ }
278
+
279
+ const pathEncoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
280
+
281
+ function randomOwnerToken() {
282
+ return ((Math.random() * 0x7ffffffe) | 0) + 1;
283
+ }
284
+
285
+ /** Claim a FREE slot. Returns the slot or null. Never blocks. */
286
+ function tryClaimSlot(layout, owner) {
287
+ const start = (Atomics.add(layout.header, SAB_IO_H_CLAIM_HINT, 1) >>> 0) % layout.slotCount;
288
+ for (let step = 0; step < layout.slotCount; step += 1) {
289
+ const slot = layout.slots[(start + step) % layout.slotCount];
290
+ if (
291
+ Atomics.compareExchange(slot.i32, SAB_IO_S_STATE, SAB_IO_SLOT_FREE, SAB_IO_SLOT_IDLE) ===
292
+ SAB_IO_SLOT_FREE
293
+ ) {
294
+ Atomics.store(slot.i32, SAB_IO_S_OWNER, owner);
295
+ return slot;
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ function releaseSlot(layout, slot) {
302
+ Atomics.store(slot.i32, SAB_IO_S_OWNER, 0);
303
+ Atomics.store(slot.i32, SAB_IO_S_STATE, SAB_IO_SLOT_FREE);
304
+ Atomics.add(layout.header, SAB_IO_H_SLOT_RELEASES, 1);
305
+ Atomics.notify(layout.header, SAB_IO_H_SLOT_RELEASES);
306
+ }
307
+
308
+ /**
309
+ * Fill a slot's request fields. `fields.path` (bytes) is copied into the slot
310
+ * data area, so the server never decodes a view over guest memory.
311
+ */
312
+ function writeRequest(slot, op, fields) {
313
+ const i32 = slot.i32;
314
+ i32[SAB_IO_S_OP] = op;
315
+ i32[SAB_IO_S_INSTANCE] = fields.instanceId ?? SAB_IO_SCRATCH_INSTANCE;
316
+ i32[SAB_IO_S_HANDLE] = fields.handle ?? -1;
317
+ i32[SAB_IO_S_FLAGS] = fields.flags ?? 0;
318
+ i32[SAB_IO_S_PTR] = fields.ptr ?? 0;
319
+ i32[SAB_IO_S_LEN] = fields.len ?? 0;
320
+ i32[SAB_IO_S_DATA_MODE] = fields.dataInSlot ? SAB_IO_DATA_SLOT : SAB_IO_DATA_MEMORY;
321
+ i32[SAB_IO_S_STATUS] = 0;
322
+ slot.f64[SAB_IO_S_F64_OFFSET] = fields.offset ?? 0;
323
+ slot.f64[SAB_IO_S_F64_RESULT] = 0;
324
+ if (fields.path) {
325
+ slot.data.set(fields.path, 0);
326
+ i32[SAB_IO_S_PATH_LEN] = fields.path.length;
327
+ } else {
328
+ i32[SAB_IO_S_PATH_LEN] = 0;
329
+ }
330
+ }
331
+
332
+ /** Status of a request, as the server returns it (i32 ops). */
333
+ function statusOf(slot) {
334
+ return Atomics.load(slot.i32, SAB_IO_S_STATUS);
335
+ }
336
+
337
+ /**
338
+ * Blocking client for one guest thread (or any Worker that may block). Every
339
+ * method returns a status or a count; none throws. Each request claims a free
340
+ * slot and releases it once its result is read.
341
+ *
342
+ * Pointers (`ptr`) address the attached memory of `instanceId` when that
343
+ * instance is ATTACHED; otherwise the client moves the bytes through the slot
344
+ * data area itself using `getMemory()` (the scratch path). Callers without a
345
+ * wasm memory use readInto/writeFrom with plain Uint8Arrays.
346
+ *
347
+ * @param {object} options
348
+ * @param {SharedArrayBuffer} options.buffer channel buffer
349
+ * @param {number} [options.instanceId] the calling instance (revocation and
350
+ * memory resolution). Defaults to the scratch instance.
351
+ * @param {() => (WebAssembly.Memory|null)} [options.getMemory] the caller's
352
+ * memory, for the scratch path of pointer-based requests.
353
+ * @param {number} [options.spinMicros=20] poll for the answer this long before
354
+ * sleeping (0 disables).
355
+ */
356
+ export function createSabIoClient(options = {}) {
357
+ assertSharedArrayBuffer(options.buffer);
358
+ const layout = describeSabIoChannel(options.buffer);
359
+ const instanceId = Number.isInteger(options.instanceId)
360
+ ? options.instanceId
361
+ : SAB_IO_SCRATCH_INSTANCE;
362
+ if (
363
+ instanceId !== SAB_IO_SCRATCH_INSTANCE &&
364
+ (instanceId < 0 || instanceId >= layout.maxInstances)
365
+ ) {
366
+ throw new RangeError(`instanceId ${instanceId} is outside 0..${layout.maxInstances - 1}.`);
367
+ }
368
+ const getMemory = typeof options.getMemory === "function" ? options.getMemory : () => null;
369
+ const spinMicros = Number.isFinite(options.spinMicros)
370
+ ? Math.max(0, options.spinMicros)
371
+ : DEFAULT_SAB_IO_CLIENT_SPIN_MICROS;
372
+ const owner = randomOwnerToken();
373
+ const doorbell = lazyBroadcast(sabIoDoorbellChannelName(layout.channelId));
374
+ let requests = 0;
375
+
376
+ // A slot is claimed per request and released as soon as its result is read.
377
+ // A client therefore holds no slot while idle, and a terminated worker leaks
378
+ // none (only a request in flight at termination, which the server still
379
+ // completes; reclaimSabIoSlots frees those).
380
+ function claim() {
381
+ for (;;) {
382
+ const releases = Atomics.load(layout.header, SAB_IO_H_SLOT_RELEASES);
383
+ const slot = tryClaimSlot(layout, owner);
384
+ if (slot) return slot;
385
+ // Every slot is busy. Wait for a release, in bounded slices.
386
+ Atomics.wait(layout.header, SAB_IO_H_SLOT_RELEASES, releases, SAB_IO_WAIT_SLICE_MS);
387
+ }
388
+ }
389
+
390
+ function roundTrip(slot, op, fields) {
391
+ const seq = (Atomics.load(slot.i32, SAB_IO_S_SEQ) + 1) | 0 || 1;
392
+ writeRequest(slot, op, fields);
393
+ Atomics.store(slot.i32, SAB_IO_S_NOTIFY, SAB_IO_NOTIFY_ATOMICS);
394
+ Atomics.store(slot.i32, SAB_IO_S_SEQ, seq);
395
+ Atomics.store(slot.i32, SAB_IO_S_STATE, SAB_IO_SLOT_PENDING);
396
+ ringDoorbell(layout, doorbell.get);
397
+ // Spin briefly first: a fast answer then costs no sleep and wake-up.
398
+ const spinUntil = spinMicros > 0 ? nowMs() + spinMicros / 1000 : 0;
399
+ for (;;) {
400
+ const state = Atomics.load(slot.i32, SAB_IO_S_STATE);
401
+ if (state === SAB_IO_SLOT_DONE && Atomics.load(slot.i32, SAB_IO_S_DONE_SEQ) === seq) {
402
+ break;
403
+ }
404
+ if (spinUntil > 0 && nowMs() < spinUntil) continue;
405
+ Atomics.wait(slot.i32, SAB_IO_S_STATE, state, SAB_IO_WAIT_SLICE_MS);
406
+ }
407
+ requests += 1;
408
+ return { status: statusOf(slot), result: slot.f64[SAB_IO_S_F64_RESULT] };
409
+ }
410
+
411
+ function request(op, fields) {
412
+ const slot = claim();
413
+ try {
414
+ return roundTrip(slot, op, fields);
415
+ } finally {
416
+ releaseSlot(layout, slot);
417
+ }
418
+ }
419
+
420
+ function attached() {
421
+ return (
422
+ instanceId !== SAB_IO_SCRATCH_INSTANCE &&
423
+ Atomics.load(layout.instances, instanceId) === SAB_IO_INSTANCE_ATTACHED
424
+ );
425
+ }
426
+
427
+ function revoked() {
428
+ if (instanceId === SAB_IO_SCRATCH_INSTANCE) return false;
429
+ const state = Atomics.load(layout.instances, instanceId);
430
+ return state === SAB_IO_INSTANCE_REVOKING || state === SAB_IO_INSTANCE_REVOKED;
431
+ }
432
+
433
+ // Move bytes between a caller view and the file through the slot data area,
434
+ // in data-area-sized pieces. `op` is SAB_IO_OP_READ or SAB_IO_OP_WRITE.
435
+ function scratchTransfer(op, handle, view, offset) {
436
+ let done = 0;
437
+ while (done < view.length) {
438
+ const piece = Math.min(layout.dataBytes, view.length - done);
439
+ const slot = claim();
440
+ let status;
441
+ try {
442
+ if (op === SAB_IO_OP_WRITE) {
443
+ slot.data.set(view.subarray(done, done + piece), 0);
444
+ }
445
+ status = roundTrip(slot, op, {
446
+ instanceId,
447
+ dataInSlot: true,
448
+ handle,
449
+ ptr: 0,
450
+ len: piece,
451
+ offset: offset + done,
452
+ }).status;
453
+ if (op === SAB_IO_OP_READ && status > 0) {
454
+ view.set(slot.data.subarray(0, status), done);
455
+ }
456
+ } finally {
457
+ releaseSlot(layout, slot);
458
+ }
459
+ if (status < 0) {
460
+ return done > 0 && op === SAB_IO_OP_READ ? done : status;
461
+ }
462
+ done += status;
463
+ if (status < piece) {
464
+ break; // short read at EOF (or a short write the host reported)
465
+ }
466
+ }
467
+ return done;
468
+ }
469
+
470
+ function memoryView(ptr, len) {
471
+ const memory = getMemory();
472
+ const buffer = memory?.buffer ?? memory;
473
+ if (!buffer || ptr < 0 || len < 0 || ptr + len > buffer.byteLength) {
474
+ return null;
475
+ }
476
+ return new Uint8Array(buffer, ptr, len);
477
+ }
478
+
479
+ return {
480
+ instanceId,
481
+ get requests() {
482
+ return requests;
483
+ },
484
+ /** Open (or probe/unlink) `path` (UTF-8 bytes, or a string). */
485
+ open(path, flags) {
486
+ const pathBytes = typeof path === "string" ? pathEncoder.encode(path) : path;
487
+ if (!(pathBytes instanceof Uint8Array) || pathBytes.length === 0) {
488
+ return FLATSQL_IO_ERR_GENERIC;
489
+ }
490
+ if (pathBytes.length > Math.min(layout.dataBytes, FLATSQL_IO_MAX_PATH_BYTES)) {
491
+ return FLATSQL_IO_ERR_GENERIC;
492
+ }
493
+ return request(SAB_IO_OP_OPEN, { instanceId, flags: flags | 0, path: pathBytes })
494
+ .status;
495
+ },
496
+ /** Read into the caller's memory at `ptr`. */
497
+ read(handle, ptr, len, offset) {
498
+ if (len === 0) return 0;
499
+ if (attached()) {
500
+ return request(SAB_IO_OP_READ, { instanceId, handle, ptr, len, offset }).status;
501
+ }
502
+ const view = memoryView(ptr, len);
503
+ if (!view) return revoked() ? FLATSQL_IO_ERR_ACCESS : FLATSQL_IO_ERR_GENERIC;
504
+ return scratchTransfer(SAB_IO_OP_READ, handle, view, offset);
505
+ },
506
+ /** Write from the caller's memory at `ptr`. */
507
+ write(handle, ptr, len, offset) {
508
+ if (len === 0) return 0;
509
+ if (attached()) {
510
+ return request(SAB_IO_OP_WRITE, { instanceId, handle, ptr, len, offset }).status;
511
+ }
512
+ const view = memoryView(ptr, len);
513
+ if (!view) return revoked() ? FLATSQL_IO_ERR_ACCESS : FLATSQL_IO_ERR_GENERIC;
514
+ return scratchTransfer(SAB_IO_OP_WRITE, handle, view, offset);
515
+ },
516
+ /** Read into a plain Uint8Array (scratch path). */
517
+ readInto(handle, view, offset) {
518
+ if (view.length === 0) return 0;
519
+ return scratchTransfer(SAB_IO_OP_READ, handle, view, offset);
520
+ },
521
+ /** Write a plain Uint8Array (scratch path). */
522
+ writeFrom(handle, view, offset) {
523
+ if (view.length === 0) return 0;
524
+ return scratchTransfer(SAB_IO_OP_WRITE, handle, view, offset);
525
+ },
526
+ truncate(handle, size) {
527
+ return request(SAB_IO_OP_TRUNCATE, { instanceId, handle, offset: size }).status;
528
+ },
529
+ sync(handle) {
530
+ return request(SAB_IO_OP_SYNC, { instanceId, handle }).status;
531
+ },
532
+ size(handle) {
533
+ const { status, result } = request(SAB_IO_OP_SIZE, { instanceId, handle });
534
+ return status < 0 ? status : result;
535
+ },
536
+ close(handle) {
537
+ return request(SAB_IO_OP_CLOSE, { instanceId, handle }).status;
538
+ },
539
+ /** The thread is exiting: close the doorbell channel. */
540
+ release() {
541
+ doorbell.close();
542
+ },
543
+ };
544
+ }
545
+
546
+ /**
547
+ * Non-blocking client for contexts that must not block: a page, the engine
548
+ * worker's event loop, or one I/O worker forwarding to another. Each request
549
+ * claims a slot, waits with `Atomics.waitAsync` (or a completion message where
550
+ * waitAsync is missing, 22.3a-5), and releases it. Data always moves through
551
+ * the slot data area.
552
+ *
553
+ * Methods resolve to a status or count; `read` resolves to a Uint8Array or a
554
+ * negative status. Nothing rejects.
555
+ */
556
+ export function createSabIoAsyncClient(options = {}) {
557
+ assertSharedArrayBuffer(options.buffer);
558
+ const layout = describeSabIoChannel(options.buffer);
559
+ const instanceId = Number.isInteger(options.instanceId)
560
+ ? options.instanceId
561
+ : SAB_IO_SCRATCH_INSTANCE;
562
+ const owner = randomOwnerToken();
563
+ const hasWaitAsync = typeof Atomics.waitAsync === "function" && options.forceMessages !== true;
564
+ const doorbell = lazyBroadcast(sabIoDoorbellChannelName(layout.channelId));
565
+ const completion = hasWaitAsync
566
+ ? null
567
+ : lazyBroadcast(sabIoCompletionChannelName(layout.channelId));
568
+ const completionWaiters = new Set();
569
+ let completionListening = false;
570
+
571
+ function listenForCompletions() {
572
+ if (completionListening || !completion) return;
573
+ const channel = completion.get();
574
+ if (!channel) return;
575
+ completionListening = true;
576
+ channel.onmessage = () => {
577
+ for (const wake of Array.from(completionWaiters)) wake();
578
+ };
579
+ }
580
+
581
+ function sleep(ms) {
582
+ return new Promise((resolve) => setTimeout(resolve, ms));
583
+ }
584
+
585
+ async function claim() {
586
+ for (;;) {
587
+ const releases = Atomics.load(layout.header, SAB_IO_H_SLOT_RELEASES);
588
+ const slot = tryClaimSlot(layout, owner);
589
+ if (slot) return slot;
590
+ if (hasWaitAsync) {
591
+ const waited = Atomics.waitAsync(
592
+ layout.header,
593
+ SAB_IO_H_SLOT_RELEASES,
594
+ releases,
595
+ SAB_IO_WAIT_SLICE_MS,
596
+ );
597
+ if (waited.async) await waited.value;
598
+ } else {
599
+ await sleep(1);
600
+ }
601
+ }
602
+ }
603
+
604
+ async function waitDone(slot, seq) {
605
+ for (;;) {
606
+ const state = Atomics.load(slot.i32, SAB_IO_S_STATE);
607
+ if (state === SAB_IO_SLOT_DONE && Atomics.load(slot.i32, SAB_IO_S_DONE_SEQ) === seq) {
608
+ return;
609
+ }
610
+ if (hasWaitAsync) {
611
+ const waited = Atomics.waitAsync(slot.i32, SAB_IO_S_STATE, state, SAB_IO_WAIT_SLICE_MS);
612
+ if (waited.async) await waited.value;
613
+ } else {
614
+ listenForCompletions();
615
+ await new Promise((resolve) => {
616
+ const wake = () => {
617
+ completionWaiters.delete(wake);
618
+ clearTimeout(timer);
619
+ resolve();
620
+ };
621
+ const timer = setTimeout(wake, SAB_IO_WAIT_SLICE_MS);
622
+ completionWaiters.add(wake);
623
+ });
624
+ }
625
+ }
626
+ }
627
+
628
+ let seqCounter = 0;
629
+ async function roundTrip(op, fields, before, after) {
630
+ const slot = await claim();
631
+ try {
632
+ const seq = (seqCounter = (seqCounter + 1) | 0 || 1);
633
+ writeRequest(slot, op, fields);
634
+ before?.(slot);
635
+ Atomics.store(
636
+ slot.i32,
637
+ SAB_IO_S_NOTIFY,
638
+ hasWaitAsync ? SAB_IO_NOTIFY_ATOMICS : SAB_IO_NOTIFY_MESSAGE,
639
+ );
640
+ Atomics.store(slot.i32, SAB_IO_S_SEQ, seq);
641
+ Atomics.store(slot.i32, SAB_IO_S_STATE, SAB_IO_SLOT_PENDING);
642
+ ringDoorbell(layout, doorbell.get);
643
+ await waitDone(slot, seq);
644
+ const status = statusOf(slot);
645
+ const result = slot.f64[SAB_IO_S_F64_RESULT];
646
+ const extra = after ? after(slot, status) : undefined;
647
+ return { status, result, extra };
648
+ } finally {
649
+ releaseSlot(layout, slot);
650
+ }
651
+ }
652
+
653
+ const encoder = new TextEncoder();
654
+
655
+ return {
656
+ instanceId,
657
+ async open(path, flags) {
658
+ const bytes = typeof path === "string" ? encoder.encode(path) : path;
659
+ if (!(bytes instanceof Uint8Array) || bytes.length === 0) return FLATSQL_IO_ERR_GENERIC;
660
+ if (bytes.length > Math.min(layout.dataBytes, FLATSQL_IO_MAX_PATH_BYTES)) {
661
+ return FLATSQL_IO_ERR_GENERIC;
662
+ }
663
+ return (await roundTrip(SAB_IO_OP_OPEN, { instanceId, flags: flags | 0, path: bytes }))
664
+ .status;
665
+ },
666
+ /** Resolves to the bytes read (possibly short at EOF) or a negative status. */
667
+ async read(handle, length, offset) {
668
+ const out = new Uint8Array(length);
669
+ let done = 0;
670
+ while (done < length) {
671
+ const piece = Math.min(layout.dataBytes, length - done);
672
+ const { status } = await roundTrip(
673
+ SAB_IO_OP_READ,
674
+ { instanceId, dataInSlot: true, handle, ptr: 0, len: piece, offset: offset + done },
675
+ null,
676
+ (slot, st) => {
677
+ if (st > 0) out.set(slot.data.subarray(0, st), done);
678
+ },
679
+ );
680
+ if (status < 0) return done > 0 ? out.subarray(0, done) : status;
681
+ done += status;
682
+ if (status < piece) break;
683
+ }
684
+ return out.subarray(0, done);
685
+ },
686
+ async write(handle, bytes, offset) {
687
+ let done = 0;
688
+ while (done < bytes.length) {
689
+ const piece = Math.min(layout.dataBytes, bytes.length - done);
690
+ const { status } = await roundTrip(
691
+ SAB_IO_OP_WRITE,
692
+ { instanceId, dataInSlot: true, handle, ptr: 0, len: piece, offset: offset + done },
693
+ (slot) => slot.data.set(bytes.subarray(done, done + piece), 0),
694
+ );
695
+ if (status < 0) return status;
696
+ done += status;
697
+ if (status < piece) break;
698
+ }
699
+ return done;
700
+ },
701
+ async truncate(handle, size) {
702
+ return (await roundTrip(SAB_IO_OP_TRUNCATE, { instanceId, handle, offset: size })).status;
703
+ },
704
+ async sync(handle) {
705
+ return (await roundTrip(SAB_IO_OP_SYNC, { instanceId, handle })).status;
706
+ },
707
+ async size(handle) {
708
+ const { status, result } = await roundTrip(SAB_IO_OP_SIZE, { instanceId, handle });
709
+ return status < 0 ? status : result;
710
+ },
711
+ async close(handle) {
712
+ return (await roundTrip(SAB_IO_OP_CLOSE, { instanceId, handle })).status;
713
+ },
714
+ close$() {
715
+ doorbell.close();
716
+ completion?.close();
717
+ },
718
+ };
719
+ }
720
+
721
+ /** Instance state as published in the channel header. */
722
+ export function sabIoInstanceState(buffer, instanceId) {
723
+ const layout = describeSabIoChannel(buffer);
724
+ return Atomics.load(layout.instances, instanceId);
725
+ }
726
+
727
+ /**
728
+ * Revoke an instance (A36). Requests from it then complete with ACCESS, and
729
+ * the server closes its handles. Resolves once the server has acknowledged, so
730
+ * no byte of that instance is written after it resolves. If the server is not
731
+ * running (dead or never started), the acknowledgement is immediate: there is
732
+ * nobody left to write.
733
+ */
734
+ export async function revokeSabIoInstance(buffer, instanceId, { pollMs = 5 } = {}) {
735
+ const layout = describeSabIoChannel(buffer);
736
+ if (instanceId < 0 || instanceId >= layout.maxInstances) {
737
+ throw new RangeError(`instanceId ${instanceId} is outside 0..${layout.maxInstances - 1}.`);
738
+ }
739
+ Atomics.store(layout.instances, instanceId, SAB_IO_INSTANCE_REVOKING);
740
+ Atomics.add(layout.header, SAB_IO_H_DOORBELL, 1);
741
+ Atomics.notify(layout.header, SAB_IO_H_DOORBELL);
742
+ if (Atomics.load(layout.header, SAB_IO_H_DOORBELL_MODE) === SAB_IO_DOORBELL_MESSAGE) {
743
+ const channel =
744
+ typeof BroadcastChannel === "function"
745
+ ? new BroadcastChannel(sabIoDoorbellChannelName(layout.channelId))
746
+ : null;
747
+ channel?.postMessage(0);
748
+ channel?.close();
749
+ }
750
+ for (;;) {
751
+ const state = Atomics.load(layout.instances, instanceId);
752
+ if (state === SAB_IO_INSTANCE_REVOKED) return;
753
+ if (Atomics.load(layout.header, SAB_IO_H_SERVER_STATE) !== SAB_IO_SERVER_RUNNING) {
754
+ Atomics.store(layout.instances, instanceId, SAB_IO_INSTANCE_REVOKED);
755
+ return;
756
+ }
757
+ if (typeof Atomics.waitAsync === "function") {
758
+ const waited = Atomics.waitAsync(layout.instances, instanceId, state, SAB_IO_WAIT_SLICE_MS);
759
+ if (waited.async) await waited.value;
760
+ } else {
761
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
762
+ }
763
+ }
764
+ }
765
+
766
+ /** Clear a revoked instance id so it can be reused by a replacement instance. */
767
+ export function resetSabIoInstance(buffer, instanceId) {
768
+ const layout = describeSabIoChannel(buffer);
769
+ Atomics.store(layout.instances, instanceId, SAB_IO_INSTANCE_NONE);
770
+ }
771
+
772
+ /**
773
+ * Free the slots an instance left behind when its threads were terminated
774
+ * mid-request (A36 step 3). Only DONE (completed, never read) and IDLE
775
+ * (claimed, never submitted) slots are freed: a PENDING or SERVICING slot is
776
+ * still owned by the server, which completes it; call again afterwards.
777
+ * Call only after every thread of the instance is gone. Returns the count.
778
+ */
779
+ export function reclaimSabIoSlots(buffer, instanceId) {
780
+ const layout = describeSabIoChannel(buffer);
781
+ let freed = 0;
782
+ for (const slot of layout.slots) {
783
+ if (Atomics.load(slot.i32, SAB_IO_S_INSTANCE) !== instanceId) continue;
784
+ for (const state of [SAB_IO_SLOT_DONE, SAB_IO_SLOT_IDLE]) {
785
+ if (Atomics.compareExchange(slot.i32, SAB_IO_S_STATE, state, SAB_IO_SLOT_FREE) === state) {
786
+ Atomics.store(slot.i32, SAB_IO_S_OWNER, 0);
787
+ freed += 1;
788
+ break;
789
+ }
790
+ }
791
+ }
792
+ if (freed > 0) {
793
+ Atomics.add(layout.header, SAB_IO_H_SLOT_RELEASES, freed);
794
+ Atomics.notify(layout.header, SAB_IO_H_SLOT_RELEASES);
795
+ }
796
+ return freed;
797
+ }
798
+
799
+ /**
800
+ * Supervisor half of A36: the I/O worker died (its `onerror` fired, or it was
801
+ * terminated). Mark the server DEAD and complete every PENDING or SERVICING
802
+ * slot with `status`, so no guest thread stays blocked. Returns the number of
803
+ * requests failed. A replacement server resets the state to RUNNING.
804
+ */
805
+ export function failPendingSabIoRequests(buffer, status = FLATSQL_IO_ERR_IO) {
806
+ const layout = describeSabIoChannel(buffer);
807
+ Atomics.store(layout.header, SAB_IO_H_SERVER_STATE, SAB_IO_SERVER_DEAD);
808
+ let failed = 0;
809
+ for (const slot of layout.slots) {
810
+ const state = Atomics.load(slot.i32, SAB_IO_S_STATE);
811
+ if (state === SAB_IO_SLOT_PENDING || state === SAB_IO_SLOT_SERVICING) {
812
+ completeSabIoSlot(layout, slot, status, 0);
813
+ failed += 1;
814
+ }
815
+ }
816
+ return failed;
817
+ }
818
+
819
+ let completionMessenger = null;
820
+ function completionChannelFor(layout) {
821
+ if (typeof BroadcastChannel !== "function") return null;
822
+ if (!completionMessenger || completionMessenger.id !== layout.channelId) {
823
+ completionMessenger?.channel.close();
824
+ completionMessenger = {
825
+ id: layout.channelId,
826
+ channel: new BroadcastChannel(sabIoCompletionChannelName(layout.channelId)),
827
+ };
828
+ }
829
+ return completionMessenger.channel;
830
+ }
831
+
832
+ /**
833
+ * Complete one slot: publish the status (and an f64 result for SIZE), mark it
834
+ * DONE for the request's sequence number, and wake its client.
835
+ */
836
+ export function completeSabIoSlot(layout, slot, status, f64Result = 0) {
837
+ slot.f64[SAB_IO_S_F64_RESULT] = f64Result;
838
+ Atomics.store(slot.i32, SAB_IO_S_STATUS, status | 0);
839
+ Atomics.store(slot.i32, SAB_IO_S_DONE_SEQ, Atomics.load(slot.i32, SAB_IO_S_SEQ));
840
+ Atomics.store(slot.i32, SAB_IO_S_STATE, SAB_IO_SLOT_DONE);
841
+ Atomics.notify(slot.i32, SAB_IO_S_STATE);
842
+ if (Atomics.load(slot.i32, SAB_IO_S_NOTIFY) === SAB_IO_NOTIFY_MESSAGE) {
843
+ completionChannelFor(layout)?.postMessage(slot.index);
844
+ }
845
+ }
846
+
847
+ export const SAB_IO_STATUS_REVOKED = FLATSQL_IO_ERR_ACCESS;
848
+ export const SAB_IO_STATUS_DEAD = FLATSQL_IO_ERR_IO;