space-data-module-sdk 0.8.20 → 0.8.22

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.
Files changed (35) hide show
  1. package/docs/isomorphic-pthreads.html +100 -0
  2. package/docs/isomorphic-pthreads.md +335 -0
  3. package/package.json +7 -2
  4. package/src/browser.js +6 -0
  5. package/src/compiler/invokeGlue.js +38 -0
  6. package/src/flow/flatsqlLinkShim.js +295 -0
  7. package/src/flow/index.d.ts +31 -0
  8. package/src/flow/index.js +8 -0
  9. package/src/host/browserCapabilityProbe.js +251 -0
  10. package/src/host/browserModuleHarness.js +24 -5
  11. package/src/host/flatsqlIo.js +14 -0
  12. package/src/host/flatsqlIoConformance.js +349 -0
  13. package/src/host/flatsqlIoContract.js +240 -0
  14. package/src/host/flatsqlIoImports.js +335 -0
  15. package/src/host/flatsqlIoMemoryBackend.js +178 -0
  16. package/src/host/flatsqlIoServer.js +960 -0
  17. package/src/host/flatsqlIoWorkers.js +323 -0
  18. package/src/host/hostWorkerBundleSources.js +11 -0
  19. package/src/host/hostWorkerBundles.js +79 -0
  20. package/src/host/index.js +9 -0
  21. package/src/host/isomorphicLoader.js +10 -2
  22. package/src/host/nodeSyncFsIo.js +633 -0
  23. package/src/host/opfsIoBackend.js +280 -0
  24. package/src/host/opfsIoWorker.mjs +223 -0
  25. package/src/host/sabIoChannel.js +848 -0
  26. package/src/host/sabIoMirror.js +306 -0
  27. package/src/host/wasiThreadBrowserWorker.mjs +21 -11
  28. package/src/host/wasiThreadHost.js +261 -43
  29. package/src/host/wasiThreadWorker.mjs +46 -8
  30. package/src/host/wasiThreadWorkerRuntime.js +54 -0
  31. package/src/index.d.ts +349 -0
  32. package/src/runtime/constants.js +6 -0
  33. package/src/testing/native/wasmedge_wasi_threads_runner.c +13 -5
  34. package/src/testing/parityHarness.js +7 -1
  35. package/src/testing/parityLanes.js +19 -0
@@ -0,0 +1,306 @@
1
+ // Small-file mirror in a SharedArrayBuffer: the A7 head mirror
2
+ // (docs/architecture/flatsql-partition-store.md §22 A7).
3
+ //
4
+ // "After each head pwrite, the writer I/O worker copies the slot into a per-pid
5
+ // seqlock mirror in a SharedArrayBuffer. In the browser, §8 step 3 reads the
6
+ // mirror, never a handle."
7
+ //
8
+ // The mirror knows nothing about heads. It keeps, per mirrored path, the first
9
+ // `entryBytes` bytes of the file plus the file's exact size, as last written
10
+ // through the writing I/O worker. A reader that consults it gets exactly what a
11
+ // read of the file would return (page-cache semantics: written, not
12
+ // necessarily flushed) without a round trip to any I/O worker, so a reader
13
+ // never queues behind a writer's flush for a head.
14
+ //
15
+ // Concurrency: one writer per path (the partition's single writer, law
16
+ // refinement 1), any number of readers. Each entry is a seqlock: the writer
17
+ // makes the sequence odd, copies, and makes it even; a reader retries when the
18
+ // sequence was odd or changed. Entries are found by open addressing on an
19
+ // FNV-1a hash of the path, and the path bytes are compared exactly.
20
+ //
21
+ // Layout (all little-endian):
22
+ // header Int32[16]: magic 'SDMR', version, entries, entryBytes, maxPathBytes
23
+ // records entries x 32 B: Int32 {state, seq, hash, pathLen, covered, rsv}
24
+ // Float64 fileSize at +24
25
+ // paths entries x maxPathBytes
26
+ // data entries x entryBytes
27
+
28
+ const MIRROR_MAGIC = 0x524d4453; // "SDMR"
29
+ const MIRROR_VERSION = 1;
30
+ const HEADER_WORDS = 16;
31
+ const RECORD_BYTES = 32;
32
+ const R_STATE = 0;
33
+ const R_SEQ = 1;
34
+ const R_HASH = 2;
35
+ const R_PATH_LEN = 3;
36
+ const R_COVERED = 4;
37
+ const R_F64_SIZE = 3; // Float64 index within the record
38
+
39
+ const STATE_EMPTY = 0;
40
+ const STATE_CLAIMING = 1;
41
+ const STATE_LIVE = 2;
42
+ const STATE_CLEARED = 3;
43
+
44
+ /** Reads give up on the mirror after this many torn attempts and use the file. */
45
+ const MAX_READ_ATTEMPTS = 64;
46
+
47
+ const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
48
+
49
+ function fnv1a(bytes) {
50
+ let hash = 0x811c9dc5;
51
+ for (let i = 0; i < bytes.length; i += 1) {
52
+ hash ^= bytes[i];
53
+ hash = Math.imul(hash, 0x01000193);
54
+ }
55
+ return hash | 0;
56
+ }
57
+
58
+ /**
59
+ * Allocate a mirror.
60
+ * @param {object} [options]
61
+ * @param {number} [options.entries=2048] mirrored files (the design sizes for
62
+ * 2,048 partitions).
63
+ * @param {number} [options.entryBytes=8192] mirrored prefix per file (a head is
64
+ * two 4 KiB slots).
65
+ * @param {number} [options.maxPathBytes=512]
66
+ */
67
+ export function createSabIoMirrorBuffer(options = {}) {
68
+ const entries = options.entries ?? 2048;
69
+ const entryBytes = options.entryBytes ?? 8192;
70
+ const maxPathBytes = options.maxPathBytes ?? 512;
71
+ if (!Number.isInteger(entries) || entries < 1) throw new RangeError("entries must be >= 1.");
72
+ if (!Number.isInteger(entryBytes) || entryBytes < 8 || entryBytes % 8 !== 0) {
73
+ throw new RangeError("entryBytes must be a positive multiple of 8.");
74
+ }
75
+ if (!Number.isInteger(maxPathBytes) || maxPathBytes < 8 || maxPathBytes % 8 !== 0) {
76
+ throw new RangeError("maxPathBytes must be a positive multiple of 8.");
77
+ }
78
+ const bytes = HEADER_WORDS * 4 + entries * (RECORD_BYTES + maxPathBytes + entryBytes);
79
+ const buffer = new SharedArrayBuffer(bytes);
80
+ const header = new Int32Array(buffer, 0, HEADER_WORDS);
81
+ header[0] = MIRROR_MAGIC;
82
+ header[1] = MIRROR_VERSION;
83
+ header[2] = entries;
84
+ header[3] = entryBytes;
85
+ header[4] = maxPathBytes;
86
+ return buffer;
87
+ }
88
+
89
+ const layoutCache = new WeakMap();
90
+
91
+ function layoutOf(buffer) {
92
+ let layout = layoutCache.get(buffer);
93
+ if (layout) return layout;
94
+ const header = new Int32Array(buffer, 0, HEADER_WORDS);
95
+ if (header[0] !== MIRROR_MAGIC || header[1] !== MIRROR_VERSION) {
96
+ throw new TypeError("Not a SAB I/O mirror buffer.");
97
+ }
98
+ const entries = header[2];
99
+ const entryBytes = header[3];
100
+ const maxPathBytes = header[4];
101
+ const recordsOffset = HEADER_WORDS * 4;
102
+ const pathsOffset = recordsOffset + entries * RECORD_BYTES;
103
+ const dataOffset = pathsOffset + entries * maxPathBytes;
104
+ layout = {
105
+ entries,
106
+ entryBytes,
107
+ maxPathBytes,
108
+ i32: new Int32Array(buffer, recordsOffset, (entries * RECORD_BYTES) / 4),
109
+ f64: new Float64Array(buffer, recordsOffset, (entries * RECORD_BYTES) / 8),
110
+ paths: new Uint8Array(buffer, pathsOffset, entries * maxPathBytes),
111
+ data: new Uint8Array(buffer, dataOffset, entries * entryBytes),
112
+ };
113
+ layoutCache.set(buffer, layout);
114
+ return layout;
115
+ }
116
+
117
+ /** The mirrored prefix length of this buffer. */
118
+ export function sabIoMirrorEntryBytes(buffer) {
119
+ return layoutOf(buffer).entryBytes;
120
+ }
121
+
122
+ function toBytes(path) {
123
+ return typeof path === "string" ? encoder.encode(path) : path;
124
+ }
125
+
126
+ function samePath(layout, index, bytes) {
127
+ const base = index * (RECORD_BYTES / 4);
128
+ if (Atomics.load(layout.i32, base + R_PATH_LEN) !== bytes.length) return false;
129
+ const start = index * layout.maxPathBytes;
130
+ for (let i = 0; i < bytes.length; i += 1) {
131
+ if (layout.paths[start + i] !== bytes[i]) return false;
132
+ }
133
+ return true;
134
+ }
135
+
136
+ /** Index of the entry for `path`, or -1. Cleared entries are returned too. */
137
+ function find(layout, bytes, hash) {
138
+ let index = (hash >>> 0) % layout.entries;
139
+ for (let probe = 0; probe < layout.entries; probe += 1) {
140
+ const base = index * (RECORD_BYTES / 4);
141
+ const state = Atomics.load(layout.i32, base + R_STATE);
142
+ if (state === STATE_EMPTY) return -1;
143
+ if (
144
+ (state === STATE_LIVE || state === STATE_CLEARED) &&
145
+ Atomics.load(layout.i32, base + R_HASH) === hash &&
146
+ samePath(layout, index, bytes)
147
+ ) {
148
+ return index;
149
+ }
150
+ index = (index + 1) % layout.entries;
151
+ }
152
+ return -1;
153
+ }
154
+
155
+ function insert(layout, bytes, hash) {
156
+ let index = (hash >>> 0) % layout.entries;
157
+ for (let probe = 0; probe < layout.entries; probe += 1) {
158
+ const base = index * (RECORD_BYTES / 4);
159
+ if (
160
+ Atomics.compareExchange(layout.i32, base + R_STATE, STATE_EMPTY, STATE_CLAIMING) ===
161
+ STATE_EMPTY
162
+ ) {
163
+ layout.paths.set(bytes, index * layout.maxPathBytes);
164
+ Atomics.store(layout.i32, base + R_PATH_LEN, bytes.length);
165
+ Atomics.store(layout.i32, base + R_HASH, hash);
166
+ Atomics.store(layout.i32, base + R_COVERED, 0);
167
+ layout.f64[index * (RECORD_BYTES / 8) + R_F64_SIZE] = 0;
168
+ Atomics.store(layout.i32, base + R_STATE, STATE_CLEARED);
169
+ return index;
170
+ }
171
+ index = (index + 1) % layout.entries;
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ /** Entry index for `path`, or -1 when the path is not mirrored. */
177
+ export function findSabIoMirrorEntry(buffer, path) {
178
+ const layout = layoutOf(buffer);
179
+ const bytes = toBytes(path);
180
+ if (bytes.length > layout.maxPathBytes) return -1;
181
+ const index = find(layout, bytes, fnv1a(bytes));
182
+ if (index < 0) return -1;
183
+ return Atomics.load(layout.i32, index * (RECORD_BYTES / 4) + R_STATE) === STATE_LIVE
184
+ ? index
185
+ : -1;
186
+ }
187
+
188
+ /**
189
+ * Record a write of `bytes` at `offset` (writer side). With `truncateTo`, the
190
+ * file's size is set to exactly that value after the write (seeding and
191
+ * truncation). Returns false when the mirror is full or the path too long;
192
+ * readers then use the file.
193
+ */
194
+ export function updateSabIoMirror(buffer, path, offset, bytes, { truncateTo } = {}) {
195
+ const layout = layoutOf(buffer);
196
+ const key = toBytes(path);
197
+ if (key.length > layout.maxPathBytes) return false;
198
+ const hash = fnv1a(key);
199
+ let index = find(layout, key, hash);
200
+ if (index < 0) index = insert(layout, key, hash);
201
+ if (index < 0) return false;
202
+ const base = index * (RECORD_BYTES / 4);
203
+ const sizeIndex = index * (RECORD_BYTES / 8) + R_F64_SIZE;
204
+ const dataStart = index * layout.entryBytes;
205
+ const wasLive = Atomics.load(layout.i32, base + R_STATE) === STATE_LIVE;
206
+
207
+ Atomics.add(layout.i32, base + R_SEQ, 1); // odd: writing
208
+ let size = wasLive ? layout.f64[sizeIndex] : 0;
209
+ let covered = wasLive ? Atomics.load(layout.i32, base + R_COVERED) : 0;
210
+ if (!wasLive) {
211
+ layout.data.fill(0, dataStart, dataStart + layout.entryBytes);
212
+ }
213
+ if (bytes.length > 0 && offset < layout.entryBytes) {
214
+ const end = Math.min(layout.entryBytes, offset + bytes.length);
215
+ if (offset > covered) {
216
+ layout.data.fill(0, dataStart + covered, dataStart + offset); // a hole reads as zeros
217
+ }
218
+ layout.data.set(bytes.subarray(0, end - offset), dataStart + offset);
219
+ }
220
+ if (bytes.length > 0) {
221
+ size = Math.max(size, offset + bytes.length);
222
+ }
223
+ if (truncateTo !== undefined) {
224
+ if (truncateTo < covered) {
225
+ layout.data.fill(0, dataStart + truncateTo, dataStart + covered);
226
+ }
227
+ size = truncateTo;
228
+ }
229
+ covered = Math.min(size, layout.entryBytes);
230
+ layout.f64[sizeIndex] = size;
231
+ Atomics.store(layout.i32, base + R_COVERED, covered);
232
+ Atomics.store(layout.i32, base + R_STATE, STATE_LIVE);
233
+ Atomics.add(layout.i32, base + R_SEQ, 1); // even: stable
234
+ return true;
235
+ }
236
+
237
+ /** Forget a path (unlinked, or its writer gave it up). */
238
+ export function clearSabIoMirror(buffer, path) {
239
+ const layout = layoutOf(buffer);
240
+ const key = toBytes(path);
241
+ if (key.length > layout.maxPathBytes) return;
242
+ const index = find(layout, key, fnv1a(key));
243
+ if (index < 0) return;
244
+ const base = index * (RECORD_BYTES / 4);
245
+ Atomics.add(layout.i32, base + R_SEQ, 1);
246
+ Atomics.store(layout.i32, base + R_STATE, STATE_CLEARED);
247
+ Atomics.store(layout.i32, base + R_COVERED, 0);
248
+ layout.f64[index * (RECORD_BYTES / 8) + R_F64_SIZE] = 0;
249
+ Atomics.add(layout.i32, base + R_SEQ, 1);
250
+ }
251
+
252
+ /**
253
+ * Serve a read from the mirror (reader side). Copies into `dst` and returns
254
+ * the byte count (short at EOF, 0 past EOF), or -1 when the mirror cannot
255
+ * answer: the path is not mirrored, or the range reaches past the mirrored
256
+ * prefix of a longer file. The caller then reads the file.
257
+ */
258
+ export function readSabIoMirror(buffer, path, dst, offset) {
259
+ const layout = layoutOf(buffer);
260
+ const key = toBytes(path);
261
+ if (key.length > layout.maxPathBytes) return -1;
262
+ const index = find(layout, key, fnv1a(key));
263
+ if (index < 0) return -1;
264
+ return readSabIoMirrorEntry(buffer, index, dst, offset);
265
+ }
266
+
267
+ /** readSabIoMirror for an entry index already found. */
268
+ export function readSabIoMirrorEntry(buffer, index, dst, offset) {
269
+ const layout = layoutOf(buffer);
270
+ const base = index * (RECORD_BYTES / 4);
271
+ const sizeIndex = index * (RECORD_BYTES / 8) + R_F64_SIZE;
272
+ const dataStart = index * layout.entryBytes;
273
+ for (let attempt = 0; attempt < MAX_READ_ATTEMPTS; attempt += 1) {
274
+ const before = Atomics.load(layout.i32, base + R_SEQ);
275
+ if (before & 1) continue;
276
+ if (Atomics.load(layout.i32, base + R_STATE) !== STATE_LIVE) return -1;
277
+ const size = layout.f64[sizeIndex];
278
+ const covered = Atomics.load(layout.i32, base + R_COVERED);
279
+ let result;
280
+ if (offset >= size) {
281
+ result = 0;
282
+ } else {
283
+ const want = Math.min(dst.length, size - offset);
284
+ if (offset + want > covered) {
285
+ result = -1;
286
+ } else {
287
+ dst.set(layout.data.subarray(dataStart + offset, dataStart + offset + want), 0);
288
+ result = want;
289
+ }
290
+ }
291
+ if (Atomics.load(layout.i32, base + R_SEQ) === before) return result;
292
+ }
293
+ return -1;
294
+ }
295
+
296
+ /** A matcher over path suffixes, e.g. ["/h.fsh"] for partition and type heads. */
297
+ export function createSabIoMirrorMatcher(suffixes = ["/h.fsh"]) {
298
+ const list = Array.from(suffixes, String);
299
+ return (path) => {
300
+ const text = String(path);
301
+ for (const suffix of list) {
302
+ if (text.endsWith(suffix) || text === suffix.replace(/^\//, "")) return true;
303
+ }
304
+ return false;
305
+ };
306
+ }
@@ -1,22 +1,29 @@
1
- import { createWasiThreadWorkerRuntime } from "./wasiThreadWorkerRuntime.js";
1
+ import {
2
+ createWasiThreadWorkerRuntime,
3
+ resolveModuleExtraImports,
4
+ } from "./wasiThreadWorkerRuntime.js";
5
+
6
+ // One pooled browser worker = one guest OS thread. It is pre-started and
7
+ // probed by createWasiThreadSpawn (wasiThreadHost.js), then runs
8
+ // wasi_thread_start for each {t:"run"} it is dispatched. The same source ships
9
+ // as a self-contained classic bundle spawned from a blob: URL (A39,
10
+ // hostWorkerBundles.js); nothing here depends on the module-worker form except
11
+ // `{ moduleUrl }` extraImports entries, which need a module worker.
2
12
 
3
- let wasmModule = null;
4
- let memory = null;
5
- let hostcallChannel = null;
6
13
  let runtime = null;
7
14
 
8
- self.onmessage = (event) => {
15
+ self.onmessage = async (event) => {
9
16
  const message = event.data ?? {};
10
17
  if (message.t === "probe") {
11
- wasmModule = message.wasmModule;
12
- memory = message.memory;
13
- hostcallChannel = message.hostcallChannel ?? null;
14
18
  try {
19
+ const extraImports = await resolveModuleExtraImports(message.extraImports ?? []);
15
20
  runtime = createWasiThreadWorkerRuntime({
16
- wasmModule,
17
- memory,
18
- hostcallChannel,
21
+ wasmModule: message.wasmModule,
22
+ memory: message.memory,
23
+ hostcallChannel: message.hostcallChannel ?? null,
19
24
  processState: message.processState,
25
+ extraImports,
26
+ workerIndex: message.workerIndex,
20
27
  });
21
28
  runtime.instantiate();
22
29
  self.postMessage({ t: "ready", ok: true });
@@ -38,6 +45,9 @@ self.onmessage = (event) => {
38
45
  instance.exports.wasi_thread_start(message.tid, message.startArg);
39
46
  } catch (error) {
40
47
  if (!(error && error.name === "WasiExitError")) {
48
+ // The thread that joins this one may be blocked in the guest and never
49
+ // handle the message below; report the fault from this worker as well.
50
+ console.error(`[wasi-thread] guest thread ${message.tid} trapped:`, error);
41
51
  self.postMessage({
42
52
  t: "error",
43
53
  tid: message.tid,