space-data-module-sdk 0.8.20 → 0.8.21

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.
@@ -146,6 +146,16 @@
146
146
  <div class="codeblock"><div class="codeblock-head">sh</div><pre><code>SPACE_DATA_MODULE_SDK_ENABLE_WASMEDGE_PARITY=1 \
147
147
  SPACE_DATA_MODULE_SDK_ENABLE_TRI_RUNTIME_PARITY=1 \
148
148
  node --test test/wasi-threads-command.test.js</code></pre></div>
149
+ <h3 id="sdk-0821-constructors-on-the-direct-surface"><a class="anchor" href="#sdk-0821-constructors-on-the-direct-surface" aria-hidden="true">#</a>SDK 0.8.21: constructors on the direct surface</h3>
150
+ <p>An artifact built with both the <code>direct</code> and the <code>command</code> surface links the WASI command runtime. Its <code>_start</code> sets up the main thread's pthread descriptor, runs the global constructors, then runs <code>main</code>, which reads stdin. A host that serves the direct surface never enters <code>_start</code>. From 0.8.21 such an artifact also exports <code>__wasm_call_ctors</code>: the same descriptor setup and constructors, without <code>main</code>, at most once per instance. Reactors keep <code>_initialize</code>.</p>
151
+ <p>A direct host runs <code>_initialize</code> if the module exports it, otherwise <code>__wasm_call_ctors</code>, once per instance, before the first direct call. The browser harness does this for <code>surface: &quot;direct&quot;</code>; a command instance only enters <code>_start</code>. The wasi-threads runner serves the direct surface of a command artifact with <code>--sdm-direct</code>, selected by <code>createStandaloneHarness(&quot;wasmedge&quot;, path, { surface: &quot;direct&quot; })</code> and by <code>runParityHarness({ surface: &quot;direct&quot; })</code>. The SDN node uses the same order.</p>
152
+ <p>Artifacts built with 0.8.20 or earlier have no such export. On their direct surface the constructors never run, in every runtime, and a threaded artifact's main thread has no pthread descriptor, so a recursive mutex held by the main thread does not exclude other threads. Rebuild them with 0.8.21.</p>
153
+ <div class="codeblock"><div class="codeblock-head">sh</div><pre><code>SPACE_DATA_MODULE_SDK_ENABLE_WASMEDGE_PARITY=1 \
154
+ SPACE_DATA_MODULE_SDK_ENABLE_TRI_RUNTIME_PARITY=1 \
155
+ node --test test/direct-call-constructors.test.js</code></pre></div>
156
+ <h3 id="guest-thread-faults"><a class="anchor" href="#guest-thread-faults" aria-hidden="true">#</a>Guest thread faults</h3>
157
+ <p>A guest thread that traps never finishes the pthread exit protocol. WasmEdge cancels the whole command. In the browser and Node harnesses the joining thread stays blocked inside the guest and cannot run the worker's error event, so the worker itself writes <code>[wasi-thread] guest thread N trapped: ...</code> to stderr (Node) or the console (browser). The call still does not return.</p>
158
+ <p>V8 (Node 20 to 25) checks bulk memory operations, and every access when the WebAssembly trap handler is off (Node on Linux arm64), against a per-instance copy of a shared memory's size. That copy is refreshed asynchronously after another thread grows the memory, so a thread that writes into memory another thread has just grown can trap with &quot;memory access out of bounds&quot;, even after synchronizing with the growing thread. The guest's allocator uses the heap the artifact was linked with and then grows the memory, so a larger imported initial memory does not prevent it.</p>
149
159
  <p>The old source path <code>src/testing/browserModuleHarness.js</code> remains a pure compatibility re-export. New browser consumers should use the public <code>space-data-module-sdk/host/browser-module</code> entry point.</p>
150
160
  <h2 id="4-integrators-the-browser-worker-anchor-required-when-you-bundle"><a class="anchor" href="#4-integrators-the-browser-worker-anchor-required-when-you-bundle" aria-hidden="true">#</a>4. Integrators: the browser worker anchor (REQUIRED when you bundle)</h2>
151
161
  <p>The browser leg of the wasi-threads host runs each guest pthread on a pooled module <code>Worker</code>. That worker is a <strong>served asset</strong>, and the SDK cannot guess where your build published it.</p>
@@ -204,6 +214,8 @@ setBrowserWasiThreadWorkerBase(&quot;js/vendor/space-data-module-sdk/src/host/&q
204
214
  <li><a class="depth-2" href="#guest-link-symbol-namespacing-collision-proof-metadata-authoritative">Guest-link symbol namespacing (collision-proof, metadata-authoritative)</a></li>
205
215
  <li><a class="depth-2" href="#3-compile-time-vs-runtime-an-honest-boundary">3. Compile-Time vs. Runtime: an honest boundary</a></li>
206
216
  <li><a class="depth-3" href="#sdk-0820-command-hosts">SDK 0.8.20 command hosts</a></li>
217
+ <li><a class="depth-3" href="#sdk-0821-constructors-on-the-direct-surface">SDK 0.8.21: constructors on the direct surface</a></li>
218
+ <li><a class="depth-3" href="#guest-thread-faults">Guest thread faults</a></li>
207
219
  <li><a class="depth-2" href="#4-integrators-the-browser-worker-anchor-required-when-you-bundle">4. Integrators: the browser worker anchor (REQUIRED when you bundle)</a></li>
208
220
  <li><a class="depth-2" href="#tests">Tests</a></li>
209
221
  <li><a class="depth-2" href="#see-also">See also</a></li></ul></nav>
@@ -216,6 +216,51 @@ SPACE_DATA_MODULE_SDK_ENABLE_TRI_RUNTIME_PARITY=1 \
216
216
  node --test test/wasi-threads-command.test.js
217
217
  ```
218
218
 
219
+ ### SDK 0.8.21: constructors on the direct surface
220
+
221
+ An artifact built with both the `direct` and the `command` surface links the
222
+ WASI command runtime. Its `_start` sets up the main thread's pthread descriptor,
223
+ runs the global constructors, then runs `main`, which reads stdin. A host that
224
+ serves the direct surface never enters `_start`. From 0.8.21 such an artifact
225
+ also exports `__wasm_call_ctors`: the same descriptor setup and constructors,
226
+ without `main`, at most once per instance. Reactors keep `_initialize`.
227
+
228
+ A direct host runs `_initialize` if the module exports it, otherwise
229
+ `__wasm_call_ctors`, once per instance, before the first direct call. The
230
+ browser harness does this for `surface: "direct"`; a command instance only
231
+ enters `_start`. The wasi-threads runner serves the direct surface of a command
232
+ artifact with `--sdm-direct`, selected by
233
+ `createStandaloneHarness("wasmedge", path, { surface: "direct" })` and by
234
+ `runParityHarness({ surface: "direct" })`. The SDN node uses the same order.
235
+
236
+ Artifacts built with 0.8.20 or earlier have no such export. On their direct
237
+ surface the constructors never run, in every runtime, and a threaded artifact's
238
+ main thread has no pthread descriptor, so a recursive mutex held by the main
239
+ thread does not exclude other threads. Rebuild them with 0.8.21.
240
+
241
+ ```sh
242
+ SPACE_DATA_MODULE_SDK_ENABLE_WASMEDGE_PARITY=1 \
243
+ SPACE_DATA_MODULE_SDK_ENABLE_TRI_RUNTIME_PARITY=1 \
244
+ node --test test/direct-call-constructors.test.js
245
+ ```
246
+
247
+ ### Guest thread faults
248
+
249
+ A guest thread that traps never finishes the pthread exit protocol. WasmEdge
250
+ cancels the whole command. In the browser and Node harnesses the joining thread
251
+ stays blocked inside the guest and cannot run the worker's error event, so the
252
+ worker itself writes `[wasi-thread] guest thread N trapped: ...` to stderr
253
+ (Node) or the console (browser). The call still does not return.
254
+
255
+ V8 (Node 20 to 25) checks bulk memory operations, and every access when the
256
+ WebAssembly trap handler is off (Node on Linux arm64), against a per-instance
257
+ copy of a shared memory's size. That copy is refreshed asynchronously after
258
+ another thread grows the memory, so a thread that writes into memory another
259
+ thread has just grown can trap with "memory access out of bounds", even after
260
+ synchronizing with the growing thread. The guest's allocator uses the heap the
261
+ artifact was linked with and then grows the memory, so a larger imported initial
262
+ memory does not prevent it.
263
+
219
264
  The old source path `src/testing/browserModuleHarness.js` remains a pure
220
265
  compatibility re-export. New browser consumers should use the public
221
266
  `space-data-module-sdk/host/browser-module` entry point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "space-data-module-sdk",
3
- "version": "0.8.20",
3
+ "version": "0.8.21",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Module SDK for building, validating, signing, and deploying WebAssembly modules on the Space Data Network.",
6
6
  "type": "module",
@@ -302,6 +302,43 @@ function renderMethodDescriptors(methods) {
302
302
  .join("\n");
303
303
  }
304
304
 
305
+ // Direct-surface initializer for command-model artifacts (wasi toolchains).
306
+ //
307
+ // A command artifact links crt1-command: its _start sets up the main-thread
308
+ // descriptor (__wasi_init_tp, threads sysroot) and runs the global
309
+ // constructors, then main. A host that serves the direct surface never enters
310
+ // _start, so without this entry namespace-scope C++ objects stay zero-filled
311
+ // and the main thread has no pthread descriptor. It is exported under the
312
+ // conventional __wasm_call_ctors name that direct hosts call when a module has
313
+ // no reactor _initialize. It runs once per instance and is a no-op on an
314
+ // instance whose constructors have already run.
315
+ const COMMAND_DIRECT_INITIALIZER_SOURCE = `#if defined(__wasi__)
316
+ extern "C" void __wasm_call_ctors(void);
317
+ extern "C" __attribute__((weak)) void __wasi_init_tp(void);
318
+
319
+ namespace {
320
+ // volatile: the optimizer would otherwise evaluate the marking constructor at
321
+ // compile time and emit the flag as already set.
322
+ volatile int g_sdm_constructors_ran = 0;
323
+
324
+ __attribute__((constructor)) void MarkConstructorsRan() {
325
+ __atomic_store_n(&g_sdm_constructors_ran, 1, __ATOMIC_SEQ_CST);
326
+ }
327
+ } // namespace
328
+
329
+ extern "C" __attribute__((export_name("__wasm_call_ctors")))
330
+ void sdm_command_direct_initialize(void) {
331
+ if (__atomic_exchange_n(&g_sdm_constructors_ran, 1, __ATOMIC_SEQ_CST) != 0) {
332
+ return;
333
+ }
334
+ if (__wasi_init_tp) {
335
+ __wasi_init_tp();
336
+ }
337
+ __wasm_call_ctors();
338
+ }
339
+ #endif
340
+ `;
341
+
305
342
  export function generateInvokeSupportSource({ manifest = {}, includeCommandMain = true } = {}) {
306
343
  const methods = Array.isArray(manifest.methods) ? manifest.methods : [];
307
344
  return `#include <algorithm>
@@ -1812,6 +1849,7 @@ extern "C" uint32_t plugin_invoke_stream(
1812
1849
  return response_ptr;
1813
1850
  }
1814
1851
 
1852
+ ${includeCommandMain ? COMMAND_DIRECT_INITIALIZER_SOURCE : ""}
1815
1853
  ${includeCommandMain
1816
1854
  ? `int main(int argc, char **argv) {
1817
1855
  const char *shortcut_method = nullptr;
@@ -297,6 +297,28 @@ function resolveManifestSurface(manifest) {
297
297
  return null;
298
298
  }
299
299
 
300
+ // Run the module's initializer, once per instance, before any direct call.
301
+ // A reactor exports `_initialize` (constructors + WASI init) and runs it on
302
+ // every instance, as before. A command artifact runs its constructors inside
303
+ // `_start`, which then runs main and consumes stdin, so a command instance
304
+ // only ever enters `_start`. An instance that serves the DIRECT surface of a
305
+ // command artifact runs the artifact's `__wasm_call_ctors` export instead:
306
+ // constructors (and main-thread setup) without main. Artifacts built before
307
+ // that export existed have no constructor entry a host can call without main;
308
+ // their direct surface stays uninitialized in every runtime alike.
309
+ function initializeInstance(instance, directSurface) {
310
+ const exports = instance.exports;
311
+ const reactorInitialize = exports[DefaultInvokeExports.reactorInitializeSymbol];
312
+ if (typeof reactorInitialize === "function") {
313
+ reactorInitialize();
314
+ return;
315
+ }
316
+ const constructors = exports[DefaultInvokeExports.constructorsSymbol];
317
+ if (directSurface && typeof constructors === "function") {
318
+ constructors();
319
+ }
320
+ }
321
+
300
322
  async function instantiateBrowserModule(options = {}) {
301
323
  let providedMemory = options.wasmMemory ?? options.memory ?? null;
302
324
  if (
@@ -412,11 +434,7 @@ async function instantiateBrowserModule(options = {}) {
412
434
  }
413
435
  wasi.setMemory(memory);
414
436
  }
415
- if (instance.exports._initialize) {
416
- instance.exports._initialize();
417
- }
418
- // Command exports initialize their CRT on entry. Calling _start here would
419
- // consume stdin before invocation; direct-only modules use _initialize.
437
+ initializeInstance(instance, options.directSurface === true);
420
438
  } catch (error) {
421
439
  await threadHost?.terminateAll();
422
440
  throw error;
@@ -543,6 +561,7 @@ export async function createBrowserModuleHarness(options = {}) {
543
561
 
544
562
  const activeContext = await instantiateBrowserModule({
545
563
  wasmModule,
564
+ directSurface: surface === "direct",
546
565
  host,
547
566
  hostcallDispatch: options.hostcallDispatch,
548
567
  args: options.args,
@@ -67,8 +67,15 @@ async function createWasmEdgeCommandHarness(options = {}) {
67
67
 
68
68
  const threaded = inspection.imports.some((entry) =>
69
69
  entry.module === "wasi" && entry.name === "thread-spawn");
70
- const surface = inspection.exports.includes(DefaultInvokeExports.commandSymbol) ? "command" : "direct";
71
- if (surface === "direct" && (!threaded || !inspection.exports.includes(DefaultInvokeExports.invokeSymbol))) {
70
+ const hasCommand = inspection.exports.includes(DefaultInvokeExports.commandSymbol);
71
+ const hasDirect = inspection.exports.includes(DefaultInvokeExports.invokeSymbol);
72
+ // The one-shot wasi-threads runner serves the direct surface of a reactor,
73
+ // and of a command artifact when the caller asks for it (--sdm-direct): the
74
+ // module's constructors then run through its initializer export, never main.
75
+ const surface = hasCommand && !(options.surface === "direct" && threaded && hasDirect)
76
+ ? "command"
77
+ : "direct";
78
+ if (surface === "direct" && (!threaded || !hasDirect)) {
72
79
  throw new Error(
73
80
  "Standalone WasmEdge loading requires a command-surface artifact with the _start export.",
74
81
  );
@@ -91,6 +98,7 @@ async function createWasmEdgeCommandHarness(options = {}) {
91
98
  }
92
99
 
93
100
  const args = [
101
+ ...(surface === "direct" && hasCommand ? ["--sdm-direct"] : []),
94
102
  ...(options.enableThreads === false ? [] : ["--enable-threads"]),
95
103
  ...Object.entries(options.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]),
96
104
  launchWasmPath,
@@ -38,6 +38,9 @@ self.onmessage = (event) => {
38
38
  instance.exports.wasi_thread_start(message.tid, message.startArg);
39
39
  } catch (error) {
40
40
  if (!(error && error.name === "WasiExitError")) {
41
+ // The thread that joins this one may be blocked in the guest and never
42
+ // handle the message below; report the fault from this worker as well.
43
+ console.error(`[wasi-thread] guest thread ${message.tid} trapped:`, error);
41
44
  self.postMessage({
42
45
  t: "error",
43
46
  tid: message.tid,
@@ -400,8 +400,9 @@ export async function createWasiThreadSpawn({
400
400
  osThreadIds.add(worker.threadId);
401
401
  }
402
402
  worker.on("error", (error) => {
403
- // A worker crash cannot be surfaced to the guest synchronously; log it
404
- // so a hung pthread_join is diagnosable rather than silent.
403
+ // A worker crash cannot be surfaced to the guest synchronously. The
404
+ // worker itself writes the fault to stderr first, because this
405
+ // handler never runs while this thread is blocked in pthread_join.
405
406
  // eslint-disable-next-line no-console
406
407
  console.error("[wasi-thread] worker error:", error);
407
408
  });
@@ -8,24 +8,46 @@
8
8
  // memory atomics (memory.atomic.wait/notify emitted by the guest) — no
9
9
  // messages are needed for correctness.
10
10
 
11
+ import { writeSync } from "node:fs";
11
12
  import { workerData } from "node:worker_threads";
12
13
 
13
14
  import { createWasiThreadWorkerRuntime } from "./wasiThreadWorkerRuntime.js";
14
15
 
15
16
  const { wasmModule, memory, tid, startArg, hostcallChannel, processState } = workerData;
17
+
18
+ // A thread that faults never completes the pthread exit protocol, so the
19
+ // thread joining it blocks for good inside the guest. When that joiner is the
20
+ // thread that owns this worker, it can never run this worker's "error" event.
21
+ // Write the fault to stderr from here, synchronously, so it is always seen.
22
+ function reportGuestThreadFault(what, error) {
23
+ try {
24
+ writeSync(2, `[wasi-thread] guest thread ${tid} ${what}: ${error?.stack ?? error}\n`);
25
+ } catch {
26
+ // stderr unavailable; the worker error event still carries the fault
27
+ }
28
+ }
29
+
16
30
  const runtime = createWasiThreadWorkerRuntime({
17
31
  wasmModule,
18
32
  memory,
19
33
  hostcallChannel,
20
34
  processState,
21
35
  });
22
- const instance = runtime.instantiate();
36
+ let instance;
37
+ try {
38
+ instance = runtime.instantiate();
39
+ } catch (error) {
40
+ reportGuestThreadFault("failed to instantiate", error);
41
+ runtime.close();
42
+ throw error;
43
+ }
23
44
 
24
45
  try {
25
46
  instance.exports.wasi_thread_start(tid, startArg);
26
47
  } catch (error) {
27
48
  // WASI proc_exit surfaces as WasiExitError; a clean thread return is normal.
28
49
  if (!(error && error.name === "WasiExitError")) {
50
+ reportGuestThreadFault("trapped", error);
29
51
  throw error;
30
52
  }
31
53
  } finally {
package/src/index.d.ts CHANGED
@@ -2790,6 +2790,8 @@ export const DefaultInvokeExports: {
2790
2790
  allocSymbol: string;
2791
2791
  freeSymbol: string;
2792
2792
  commandSymbol: string;
2793
+ reactorInitializeSymbol: string;
2794
+ constructorsSymbol: string;
2793
2795
  };
2794
2796
 
2795
2797
  export const DrainPolicy: {
@@ -74,4 +74,10 @@ export const DefaultInvokeExports = Object.freeze({
74
74
  allocSymbol: "plugin_alloc",
75
75
  freeSymbol: "plugin_free",
76
76
  commandSymbol: "_start",
77
+ // Direct-surface initialization. A reactor exports `_initialize`; a command
78
+ // artifact that also serves the direct surface exports `__wasm_call_ctors`
79
+ // (its `_start` would run main). A direct host calls the first one present,
80
+ // once per instance, before the first direct call.
81
+ reactorInitializeSymbol: "_initialize",
82
+ constructorsSymbol: "__wasm_call_ctors",
77
83
  });
@@ -150,9 +150,12 @@ static WasmEdge_ModuleInstanceContext *memory_import(Threads *g) {
150
150
  return env;
151
151
  }
152
152
 
153
- // Reactor artifacts use the same PIV request/response bytes as commands. Stage
154
- // the request through the public allocator, then monitor the direct call with
155
- // the same worker-group lifecycle as _start. No guest ABI is reimplemented.
153
+ // Direct calls use the same PIV request/response bytes as commands. Stage the
154
+ // request through the public allocator, then monitor the direct call with the
155
+ // same worker-group lifecycle as _start. No guest ABI is reimplemented. The
156
+ // module initializes first, once: a reactor through _initialize, a command
157
+ // artifact serving the direct surface through __wasm_call_ctors (its _start
158
+ // would run main).
156
159
  static uint32_t stage_direct_request(Threads *g,
157
160
  WasmEdge_ModuleInstanceContext *instance,
158
161
  WasmEdge_MemoryInstanceContext *memory, WasmEdge_Value args[3]) {
@@ -171,6 +174,7 @@ static uint32_t stage_direct_request(Threads *g,
171
174
  }
172
175
  const WasmEdge_FunctionInstanceContext *init =
173
176
  WasmEdge_ModuleInstanceFindFunction(instance, name("_initialize"));
177
+ if (!init) init = WasmEdge_ModuleInstanceFindFunction(instance, name("__wasm_call_ctors"));
174
178
  if (init) require_result("initialize", WasmEdge_ExecutorInvoke(g->executor, init, NULL, 0, NULL, 0));
175
179
  const WasmEdge_FunctionInstanceContext *alloc =
176
180
  WasmEdge_ModuleInstanceFindFunction(instance, name("plugin_alloc"));
@@ -224,10 +228,12 @@ int main(int argc, char **argv) {
224
228
  if (!envs) return 1;
225
229
  uint32_t env_count = 0;
226
230
  bool stats = false;
231
+ bool force_direct = false;
227
232
  int first = 1;
228
233
  for (; first < argc; ++first) {
229
234
  if (strcmp(argv[first], "--enable-threads") == 0) continue;
230
235
  if (strcmp(argv[first], "--sdm-thread-stats") == 0) { stats = true; continue; }
236
+ if (strcmp(argv[first], "--sdm-direct") == 0) { force_direct = true; continue; }
231
237
  if (strcmp(argv[first], "--env") == 0 && first + 1 < argc) {
232
238
  envs[env_count++] = argv[++first];
233
239
  continue;
@@ -235,7 +241,7 @@ int main(int argc, char **argv) {
235
241
  break;
236
242
  }
237
243
  if (first >= argc || argv[first][0] == '-') {
238
- fprintf(stderr, "usage: %s [--env NAME=VALUE] module.wasm [args...]\n", argv[0]);
244
+ fprintf(stderr, "usage: %s [--sdm-direct] [--env NAME=VALUE] module.wasm [args...]\n", argv[0]);
239
245
  free(envs);
240
246
  return 2;
241
247
  }
@@ -269,7 +275,9 @@ int main(int argc, char **argv) {
269
275
  WasmEdge_ModuleInstanceContext *instance = NULL;
270
276
  require_result("instantiate", WasmEdge_ExecutorInstantiate(
271
277
  g.executor, &instance, g.store, g.ast));
272
- const WasmEdge_FunctionInstanceContext *start =
278
+ // --sdm-direct serves the direct surface of an artifact that also has a
279
+ // command entry; a reactor (no _start) is always served directly.
280
+ const WasmEdge_FunctionInstanceContext *start = force_direct ? NULL :
273
281
  WasmEdge_ModuleInstanceFindFunction(instance, name("_start"));
274
282
  bool direct = start == NULL;
275
283
  WasmEdge_Value direct_args[3], response_pointer;
@@ -499,6 +499,11 @@ function applyInjectedDivergence(runs, injectDivergence) {
499
499
  * @param {string[]} [options.lanes] - subset of PARITY_LANES. Defaults to ALL
500
500
  * THREE. Narrowing is always an explicit caller decision — lanes are never
501
501
  * skipped silently, and a single-lane run can never claim parity.
502
+ * @param {string} [options.surface] - "direct" runs EVERY lane on the direct
503
+ * surface: the browser harness, and the wasi-threads runner with
504
+ * --sdm-direct (the WasmEdge CLI has no direct surface, so a single-thread
505
+ * artifact cannot be compared on it). Omitted: the browser lane follows
506
+ * browserSurface and the WasmEdge lanes run the artifact's default surface.
502
507
  * @param {Object} [options.laneRunners] - {laneName: async (context) => runs[]}
503
508
  * override for tests. Default runners come from parityLanes.js.
504
509
  * @param {string} [options.injectDivergence] - fire-drill: XOR one output byte
@@ -555,7 +560,8 @@ export async function runParityHarness(options = {}) {
555
560
  chromeBinary: options.chromeBinary,
556
561
  wasmedgeBinary: options.wasmedgeBinary,
557
562
  wasmEdgeRunnerBinary: options.wasmEdgeRunnerBinary,
558
- browserSurface: options.browserSurface ??
563
+ surface: options.surface ?? null,
564
+ browserSurface: options.browserSurface ?? options.surface ??
559
565
  (WebAssembly.Module.exports(await WebAssembly.compile(loadableBytes)).some((entry) => entry.name === "_start") ? "command" : "direct"),
560
566
  dockerBinary: options.dockerBinary,
561
567
  dockerPlatform: options.dockerPlatform,
@@ -130,6 +130,21 @@ function wasmedgeInvocationArgs(planCase, plan, threadCount) {
130
130
  return args;
131
131
  }
132
132
 
133
+ // Runner flags that select the surface. Only the wasi-threads runner serves the
134
+ // direct surface of an artifact with a command entry; the WasmEdge CLI runs
135
+ // `_start` or nothing, so a direct-surface comparison on it would silently
136
+ // compare two different surfaces.
137
+ function surfaceArgs(context, threaded, lane) {
138
+ if (context.surface !== "direct") return [];
139
+ if (!threaded) {
140
+ throw new Error(
141
+ `${lane}: the WasmEdge CLI serves only the command surface of a single-thread artifact; ` +
142
+ "it cannot take part in a direct-surface parity run.",
143
+ );
144
+ }
145
+ return ["--sdm-direct"];
146
+ }
147
+
133
148
  async function stageModuleWorkdir(context, label) {
134
149
  const workdir = await mkdtemp(path.join(os.tmpdir(), `sdm-parity-${label}-`));
135
150
  // Stage the canonical loadable bytes (publication records stripped by the
@@ -183,6 +198,7 @@ export async function runNativeWasmEdgeLane(context) {
183
198
  `native binary ${binary}`,
184
199
  );
185
200
 
201
+ const directArgs = surfaceArgs(context, threaded, "native WasmEdge lane");
186
202
  const workdir = await stageModuleWorkdir(context, "native");
187
203
  const runs = [];
188
204
  try {
@@ -191,6 +207,7 @@ export async function runNativeWasmEdgeLane(context) {
191
207
  const outcome = await spawnWithStdin(
192
208
  binary,
193
209
  [...(threaded ? ["--sdm-thread-stats"] : []),
210
+ ...directArgs,
194
211
  ...wasmedgeInvocationArgs(planCase, context.plan, threadCount)],
195
212
  {
196
213
  cwd: workdir,
@@ -288,6 +305,7 @@ export async function runDockerWasmEdgeLane(context) {
288
305
  `docker WasmEdge lane: cannot execute "${dockerBinary}" (${error.message}).`,
289
306
  );
290
307
  }
308
+ const directArgs = surfaceArgs(context, threaded, "docker WasmEdge lane");
291
309
  const image = await ensureDockerParityImage(context);
292
310
 
293
311
  const workdir = await stageModuleWorkdir(context, "docker");
@@ -313,6 +331,7 @@ export async function runDockerWasmEdgeLane(context) {
313
331
  dockerArgs.push(
314
332
  image,
315
333
  ...(threaded ? ["--sdm-thread-stats"] : []),
334
+ ...directArgs,
316
335
  ...wasmedgeInvocationArgs(planCase, context.plan, threadCount),
317
336
  );
318
337
  const outcome = await spawnWithStdin(dockerBinary, dockerArgs, {