space-data-module-sdk 0.8.23 → 0.8.25

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.
@@ -1,17 +1,41 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { cp, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
- import { readFileSync } from "node:fs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import {
5
+ cp,
6
+ lstat,
7
+ mkdtemp,
8
+ readFile,
9
+ readdir,
10
+ rename,
11
+ rm,
12
+ stat,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { constants as fsConstants, createReadStream, readFileSync } from "node:fs";
5
16
  import { createRequire } from "node:module";
6
17
  import { pathToFileURL } from "node:url";
7
18
 
8
19
  const require = createRequire(import.meta.url);
20
+ // Bump when the patched tree's layout or meaning changes. The patch transform's
21
+ // own source text is part of the cache key as well (see
22
+ // computeEmceptionIdentity), so editing patchEmceptionModuleSource cannot reuse
23
+ // a tree patched by an older transform even if this string is left alone.
9
24
  const PATCH_VERSION = "space-data-module-sdk-emception-node-v1";
25
+ const PATCH_KEY_TAG = "node-v1";
10
26
  const PATCH_MARKER_FILENAME = ".space-data-module-sdk-emception-patch";
11
- const EMCEPTION_PATCH_ROOT = path.join(
12
- os.tmpdir(),
13
- `space-data-module-sdk-emception-node-${process.pid}`,
27
+ // One shared root per (patch, emception build) lives at
28
+ // <tmpdir>/space-data-module-sdk-emception-<tag>-<version>-<fingerprint>-u<uid>.
29
+ // Every process of every consumer run by that user reuses it. Builds happen in a sibling
30
+ // staging dir that is renamed into place only once it is complete.
31
+ const PATCHED_ROOT_PREFIX = "space-data-module-sdk-emception-";
32
+ const STAGING_MARKER = ".staging-";
33
+ const STALE_MARKER = ".stale-";
34
+ const ORPHAN_NAME_PATTERN = new RegExp(
35
+ `^${PATCHED_ROOT_PREFIX.replaceAll("-", "\\-")}.+\\.(?:staging|stale)-(\\d+)-[A-Za-z0-9]+$`,
14
36
  );
37
+ const RENAME_COLLISION_CODES = new Set(["ENOTEMPTY", "EEXIST", "EPERM", "EBUSY"]);
38
+ const MAX_PUBLISH_ATTEMPTS = 4;
15
39
  const FILE_URL_FETCH_PATCH_FLAG =
16
40
  "__spaceDataModuleSdkFileUrlFetchPatched";
17
41
 
@@ -84,90 +108,335 @@ function installNodeRuntimeShims() {
84
108
  }
85
109
  }
86
110
 
87
- async function patchEmceptionModuleTree(rootDir) {
88
- const entries = await readdir(rootDir, { withFileTypes: true });
111
+ // Pure transform of one emception .mjs file. Its source text is hashed into
112
+ // the cache key, so any edit here yields a fresh patched root.
113
+ function patchEmceptionModuleSource(fileName, source) {
114
+ let patched = source.replaceAll(
115
+ 'scriptDirectory=__dirname+"/"',
116
+ 'scriptDirectory=(new URL(".", import.meta.url)).pathname',
117
+ );
118
+
119
+ if (fileName === "emception.mjs") {
120
+ patched = patched.replace(
121
+ "this.#fs = await new FileSystem();",
122
+ [
123
+ "this.#fs = await new FileSystem({",
124
+ " locateFile: (file, scriptDirectory) => scriptDirectory + file,",
125
+ ' cache: "/tmp/emception-cache",',
126
+ " });",
127
+ ].join("\n"),
128
+ );
129
+ patched = patched.replace(
130
+ "const config = {",
131
+ [
132
+ "const config = {",
133
+ " locateFile: (file, scriptDirectory) => scriptDirectory + file,",
134
+ ].join("\n"),
135
+ );
136
+ }
137
+
138
+ if (fileName === "FileSystem.mjs") {
139
+ patched = patched.replace(
140
+ [
141
+ " if (!this.exists(cache)) {",
142
+ " this.persist(cache);",
143
+ " }",
144
+ " await this.pull();",
145
+ ].join("\n"),
146
+ [
147
+ ' if (typeof indexedDB !== "undefined") {',
148
+ " if (!this.exists(cache)) {",
149
+ " this.persist(cache);",
150
+ " }",
151
+ " await this.pull();",
152
+ " }",
153
+ ].join("\n"),
154
+ );
155
+ }
156
+
157
+ return patched;
158
+ }
159
+
160
+ // Relative POSIX paths of every regular file under rootDir, sorted.
161
+ async function listTreeFiles(rootDir, relativeDir = "") {
162
+ const entries = await readdir(path.join(rootDir, relativeDir), {
163
+ withFileTypes: true,
164
+ });
165
+ const files = [];
89
166
  for (const entry of entries) {
90
- const fullPath = path.join(rootDir, entry.name);
167
+ const relativePath = relativeDir
168
+ ? path.posix.join(relativeDir, entry.name)
169
+ : entry.name;
91
170
  if (entry.isDirectory()) {
92
- await patchEmceptionModuleTree(fullPath);
93
- continue;
171
+ files.push(...(await listTreeFiles(rootDir, relativePath)));
172
+ } else if (entry.isFile()) {
173
+ files.push(relativePath);
94
174
  }
95
- if (!entry.name.endsWith(".mjs")) {
175
+ }
176
+ return files.sort();
177
+ }
178
+
179
+ async function patchEmceptionModuleTree(rootDir, files) {
180
+ for (const relativePath of files) {
181
+ if (!relativePath.endsWith(".mjs")) {
96
182
  continue;
97
183
  }
98
-
99
- let source = await readFile(fullPath, "utf8");
100
- source = source.replaceAll(
101
- 'scriptDirectory=__dirname+"/"',
102
- 'scriptDirectory=(new URL(".", import.meta.url)).pathname',
184
+ const fullPath = path.join(rootDir, relativePath);
185
+ const source = await readFile(fullPath, "utf8");
186
+ const patched = patchEmceptionModuleSource(
187
+ path.posix.basename(relativePath),
188
+ source,
103
189
  );
104
-
105
- if (entry.name === "emception.mjs") {
106
- source = source.replace(
107
- "this.#fs = await new FileSystem();",
108
- [
109
- "this.#fs = await new FileSystem({",
110
- " locateFile: (file, scriptDirectory) => scriptDirectory + file,",
111
- ' cache: "/tmp/emception-cache",',
112
- " });",
113
- ].join("\n"),
114
- );
115
- source = source.replace(
116
- "const config = {",
117
- [
118
- "const config = {",
119
- " locateFile: (file, scriptDirectory) => scriptDirectory + file,",
120
- ].join("\n"),
121
- );
190
+ if (patched !== source) {
191
+ await writeFile(fullPath, patched, "utf8");
122
192
  }
193
+ }
194
+ }
123
195
 
124
- if (entry.name === "FileSystem.mjs") {
125
- source = source.replace(
126
- [
127
- " if (!this.exists(cache)) {",
128
- " this.persist(cache);",
129
- " }",
130
- " await this.pull();",
131
- ].join("\n"),
132
- [
133
- ' if (typeof indexedDB !== "undefined") {',
134
- " if (!this.exists(cache)) {",
135
- " this.persist(cache);",
136
- " }",
137
- " await this.pull();",
138
- " }",
139
- ].join("\n"),
196
+ async function readEmceptionPackageVersion(sourceRoot) {
197
+ let directory = sourceRoot;
198
+ for (;;) {
199
+ try {
200
+ const packageJson = JSON.parse(
201
+ await readFile(path.join(directory, "package.json"), "utf8"),
140
202
  );
203
+ if (packageJson?.name === "sdn-emception") {
204
+ return String(packageJson.version ?? "unknown");
205
+ }
206
+ } catch {
207
+ // Keep walking up.
141
208
  }
209
+ const parent = path.dirname(directory);
210
+ if (parent === directory) {
211
+ return "unknown";
212
+ }
213
+ directory = parent;
214
+ }
215
+ }
142
216
 
143
- await writeFile(fullPath, source, "utf8");
217
+ // The cache key: the patch version, the patch transform, the emception
218
+ // package version, and a sha256 over every source file's path, size and
219
+ // bytes. Two installs with the same version but different bytes never share
220
+ // a root.
221
+ async function computeEmceptionIdentity(sourceRoot) {
222
+ const version = await readEmceptionPackageVersion(sourceRoot);
223
+ const files = await listTreeFiles(sourceRoot);
224
+ const hash = createHash("sha256");
225
+ hash.update(`${PATCH_VERSION}\0${patchEmceptionModuleSource.toString()}\0`);
226
+ hash.update(`${version}\0`);
227
+ for (const relativePath of files) {
228
+ const fullPath = path.join(sourceRoot, relativePath);
229
+ const { size } = await stat(fullPath);
230
+ hash.update(`${relativePath}\0${size}\0`);
231
+ for await (const chunk of createReadStream(fullPath)) {
232
+ hash.update(chunk);
233
+ }
234
+ hash.update("\0");
144
235
  }
236
+ const fingerprint = hash.digest("hex");
237
+ const safeVersion = version.replace(/[^A-Za-z0-9._]/g, "_");
238
+ // Per user: where the tmp dir is shared (Linux /tmp), users never collide.
239
+ const uid = currentUid();
240
+ return {
241
+ version,
242
+ fingerprint,
243
+ files,
244
+ key: `${PATCH_KEY_TAG}-${safeVersion}-${fingerprint.slice(0, 16)}${uid === null ? "" : `-u${uid}`}`,
245
+ };
145
246
  }
146
247
 
147
- async function preparePatchedEmceptionRoot() {
148
- if (!patchedEmceptionRootPromise) {
149
- patchedEmceptionRootPromise = (async () => {
150
- const sourceRoot = path.dirname(require.resolve("sdn-emception"));
151
- const markerPath = path.join(EMCEPTION_PATCH_ROOT, PATCH_MARKER_FILENAME);
152
- let marker = null;
153
- try {
154
- marker = await readFile(markerPath, "utf8");
155
- } catch {
156
- marker = null;
248
+ // The content check on reuse. The marker is written last inside the staging
249
+ // dir, so a published root always has one; this also catches a root whose
250
+ // files were later deleted or truncated (tmp cleaners purge by age).
251
+ async function isPatchedRootValid(rootDir, identity) {
252
+ // The root is imported as code, so it must be a real directory that this
253
+ // user owns and nobody else can write. Anything else is never trusted.
254
+ try {
255
+ const rootStat = await lstat(rootDir);
256
+ if (!rootStat.isDirectory()) {
257
+ return false;
258
+ }
259
+ const uid = currentUid();
260
+ if (uid !== null && (rootStat.uid !== uid || (rootStat.mode & 0o022) !== 0)) {
261
+ return false;
262
+ }
263
+ } catch {
264
+ return false;
265
+ }
266
+ let marker;
267
+ try {
268
+ marker = JSON.parse(
269
+ await readFile(path.join(rootDir, PATCH_MARKER_FILENAME), "utf8"),
270
+ );
271
+ } catch {
272
+ return false;
273
+ }
274
+ if (
275
+ marker?.patchVersion !== PATCH_VERSION ||
276
+ marker.emceptionVersion !== identity.version ||
277
+ marker.fingerprint !== identity.fingerprint ||
278
+ !Array.isArray(marker.files) ||
279
+ marker.files.length !== identity.files.length
280
+ ) {
281
+ return false;
282
+ }
283
+ for (let index = 0; index < marker.files.length; index += 1) {
284
+ const [relativePath, size] = marker.files[index] ?? [];
285
+ if (relativePath !== identity.files[index]) {
286
+ return false;
287
+ }
288
+ try {
289
+ const entry = await stat(path.join(rootDir, relativePath));
290
+ if (!entry.isFile() || entry.size !== size) {
291
+ return false;
157
292
  }
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+ return true;
298
+ }
299
+
300
+ function currentUid() {
301
+ return typeof process.getuid === "function" ? process.getuid() : null;
302
+ }
303
+
304
+ function isProcessAlive(pid) {
305
+ try {
306
+ process.kill(pid, 0);
307
+ return true;
308
+ } catch (error) {
309
+ return error?.code === "EPERM";
310
+ }
311
+ }
158
312
 
159
- if (marker?.trim() !== PATCH_VERSION) {
160
- await rm(EMCEPTION_PATCH_ROOT, { recursive: true, force: true });
161
- await cp(sourceRoot, EMCEPTION_PATCH_ROOT, { recursive: true });
162
- await patchEmceptionModuleTree(EMCEPTION_PATCH_ROOT);
163
- await writeFile(markerPath, `${PATCH_VERSION}\n`, "utf8");
313
+ // Staging and stale dirs carry their creator's pid. A process that died
314
+ // mid-build leaves one behind; nothing else would ever remove it.
315
+ async function removeOrphanedBuildDirs(cacheRoot) {
316
+ let names;
317
+ try {
318
+ names = await readdir(cacheRoot);
319
+ } catch {
320
+ return;
321
+ }
322
+ for (const name of names) {
323
+ const match = ORPHAN_NAME_PATTERN.exec(name);
324
+ if (!match) {
325
+ continue;
326
+ }
327
+ const pid = Number(match[1]);
328
+ if (pid === process.pid || isProcessAlive(pid)) {
329
+ continue;
330
+ }
331
+ const uid = currentUid();
332
+ if (uid !== null) {
333
+ const entry = await lstat(path.join(cacheRoot, name)).catch(() => null);
334
+ if (!entry || entry.uid !== uid) {
335
+ continue;
164
336
  }
337
+ }
338
+ await rm(path.join(cacheRoot, name), { recursive: true, force: true }).catch(
339
+ () => {},
340
+ );
341
+ }
342
+ }
165
343
 
166
- return EMCEPTION_PATCH_ROOT;
167
- })().catch((error) => {
168
- patchedEmceptionRootPromise = null;
344
+ // rename(2) of a directory is atomic: the root either does not exist or is
345
+ // complete. Losing the race to another process is success when its root
346
+ // passes the content check.
347
+ async function publishPatchedRoot(stagingDir, finalDir, identity) {
348
+ let lastError = null;
349
+ for (let attempt = 0; attempt < MAX_PUBLISH_ATTEMPTS; attempt += 1) {
350
+ try {
351
+ await rename(stagingDir, finalDir);
352
+ return;
353
+ } catch (error) {
354
+ if (!RENAME_COLLISION_CODES.has(error?.code)) {
355
+ throw error;
356
+ }
357
+ lastError = error;
358
+ }
359
+ if (await isPatchedRootValid(finalDir, identity)) {
360
+ return;
361
+ }
362
+ // What sits at finalDir is incomplete. Move it aside in one rename so no
363
+ // reader ever sees it half-deleted, then retry.
364
+ const staleDir = `${finalDir}${STALE_MARKER}${process.pid}-${randomBytes(4).toString("hex")}`;
365
+ try {
366
+ await rename(finalDir, staleDir);
367
+ } catch (error) {
368
+ if (error?.code === "ENOENT") {
369
+ continue;
370
+ }
169
371
  throw error;
372
+ }
373
+ await rm(staleDir, { recursive: true, force: true });
374
+ }
375
+ throw new Error(
376
+ `Could not publish the patched emception root at ${finalDir}.`,
377
+ { cause: lastError },
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Returns the shared patched emception root under `cacheRoot` (default
383
+ * `os.tmpdir()`), building it first if it is missing or fails the content
384
+ * check. Safe to call from any number of processes at once.
385
+ */
386
+ export async function preparePatchedEmceptionRoot(options = {}) {
387
+ const cacheRoot = options.cacheRoot ?? os.tmpdir();
388
+ const sourceRoot =
389
+ options.sourceRoot ?? path.dirname(require.resolve("sdn-emception"));
390
+ const identity = await computeEmceptionIdentity(sourceRoot);
391
+ const finalDir = path.join(cacheRoot, `${PATCHED_ROOT_PREFIX}${identity.key}`);
392
+ if (await isPatchedRootValid(finalDir, identity)) {
393
+ return finalDir;
394
+ }
395
+
396
+ await removeOrphanedBuildDirs(cacheRoot);
397
+ const stagingDir = await mkdtemp(
398
+ `${finalDir}${STAGING_MARKER}${process.pid}-`,
399
+ );
400
+ try {
401
+ // Copy-on-write clone where the filesystem supports it (APFS, btrfs);
402
+ // a plain copy elsewhere.
403
+ await cp(sourceRoot, stagingDir, {
404
+ recursive: true,
405
+ mode: fsConstants.COPYFILE_FICLONE,
170
406
  });
407
+ await patchEmceptionModuleTree(stagingDir, identity.files);
408
+ const files = [];
409
+ for (const relativePath of identity.files) {
410
+ const { size } = await stat(path.join(stagingDir, relativePath));
411
+ files.push([relativePath, size]);
412
+ }
413
+ await writeFile(
414
+ path.join(stagingDir, PATCH_MARKER_FILENAME),
415
+ `${JSON.stringify({
416
+ patchVersion: PATCH_VERSION,
417
+ emceptionVersion: identity.version,
418
+ fingerprint: identity.fingerprint,
419
+ files,
420
+ })}\n`,
421
+ "utf8",
422
+ );
423
+ await publishPatchedRoot(stagingDir, finalDir, identity);
424
+ } finally {
425
+ // A no-op after a successful rename; removes the copy after a lost race
426
+ // or a failure.
427
+ await rm(stagingDir, { recursive: true, force: true });
428
+ }
429
+ return finalDir;
430
+ }
431
+
432
+ function getPatchedEmceptionRoot() {
433
+ if (!patchedEmceptionRootPromise) {
434
+ patchedEmceptionRootPromise = preparePatchedEmceptionRoot().catch(
435
+ (error) => {
436
+ patchedEmceptionRootPromise = null;
437
+ throw error;
438
+ },
439
+ );
171
440
  }
172
441
 
173
442
  return patchedEmceptionRootPromise;
@@ -181,7 +450,7 @@ class EmceptionController {
181
450
  if (!this.#instancePromise) {
182
451
  this.#instancePromise = (async () => {
183
452
  installNodeRuntimeShims();
184
- const patchedRoot = await preparePatchedEmceptionRoot();
453
+ const patchedRoot = await getPatchedEmceptionRoot();
185
454
  const moduleUrl = pathToFileURL(path.join(patchedRoot, "emception.mjs")).href;
186
455
  const { default: Emception } = await import(moduleUrl);
187
456
  const emception = new Emception({
@@ -384,6 +384,7 @@ async function instantiateBrowserModule(options = {}) {
384
384
  // Browser warm-pool sizing: cap pooled workers at how many guest threads
385
385
  // the module will actually ask for. Ignored by the Node lazy path.
386
386
  requestedThreads: options.maxThreads,
387
+ poolSize: options.wasiThreadPoolSize,
387
388
  hostcallChannel: options.threadHostcallChannel,
388
389
  requiresHostcalls: needsHostBridge,
389
390
  enableBrowserThreads: options.enableBrowserWasiThreads,
@@ -395,6 +396,7 @@ async function instantiateBrowserModule(options = {}) {
395
396
  // packaged sibling, i.e. today's unbundled behavior.
396
397
  browserWorkerBaseUrl: options.wasiThreadWorkerBaseUrl,
397
398
  browserWorkerUrl: options.wasiThreadWorkerUrl,
399
+ spawnWaitMs: options.wasiThreadSpawnWaitMs,
398
400
  });
399
401
  importObject.wasi = {
400
402
  ...(importObject.wasi ?? {}),
@@ -469,6 +471,13 @@ async function instantiateBrowserModule(options = {}) {
469
471
  * @param {number} [options.maxThreads] - Upper bound on guest threads; sizes the
470
472
  * browser wasi-threads warm pool. Ignored by single-thread artifacts and by
471
473
  * the Node lazy worker path.
474
+ * @param {number} [options.wasiThreadPoolSize] - Explicit wasi-threads pool size
475
+ * (createWasiThreadSpawn `poolSize`): the browser pre-starts exactly this many
476
+ * workers; Node caps live guest threads at it. Unset: the browser pool is
477
+ * min(hardwareConcurrency - 1, maxThreads) and Node spawns without a cap.
478
+ * @param {number} [options.wasiThreadSpawnWaitMs] - How long a guest spawn that
479
+ * finds every pooled thread busy waits for one to finish before
480
+ * pthread_create fails (createWasiThreadSpawn `spawnWaitMs`, default 250).
472
481
  */
473
482
  export async function createBrowserModuleHarness(options = {}) {
474
483
  let wasmSource = options.wasmSource;
@@ -578,6 +587,8 @@ export async function createBrowserModuleHarness(options = {}) {
578
587
  enableBrowserWasiThreads: options.enableBrowserWasiThreads,
579
588
  wasiThreadWorkerBaseUrl: options.wasiThreadWorkerBaseUrl,
580
589
  wasiThreadWorkerUrl: options.wasiThreadWorkerUrl,
590
+ wasiThreadSpawnWaitMs: options.wasiThreadSpawnWaitMs,
591
+ wasiThreadPoolSize: options.wasiThreadPoolSize,
581
592
  });
582
593
  const { instance, bridge, wasi, memory, threadHost } = activeContext;
583
594
  let lastCommandContext = null;
@@ -842,6 +853,8 @@ export async function createBrowserModuleHarness(options = {}) {
842
853
  enableBrowserWasiThreads: options.enableBrowserWasiThreads,
843
854
  wasiThreadWorkerBaseUrl: options.wasiThreadWorkerBaseUrl,
844
855
  wasiThreadWorkerUrl: options.wasiThreadWorkerUrl,
856
+ wasiThreadSpawnWaitMs: options.wasiThreadSpawnWaitMs,
857
+ wasiThreadPoolSize: options.wasiThreadPoolSize,
845
858
  });
846
859
  lastCommandContext = commandContext;
847
860
  try {