@synsema/wasm 0.6.44 → 0.6.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,10 @@ store, an `llm`, a `log`. No Synsema backend, nothing to install on the host.
8
8
  ```js
9
9
  import { Synsema } from "@synsema/wasm";
10
10
 
11
- const syn = await Synsema.load(new URL("@synsema/wasm/synsema.wasm", import.meta.url));
11
+ // Node (also Next.js API routes and Server Actions): the .wasm that ships in this package.
12
+ const syn = await Synsema.loadNode();
13
+ // Browser / edge: serve synsema.wasm with your static files and load it by URL:
14
+ // const syn = await Synsema.load("/synsema.wasm");
12
15
  await syn.ready();
13
16
 
14
17
  const r = syn.run(`print(keccak256("hola"))`);
@@ -19,6 +22,42 @@ r.audit; // every capability check: {capability, granted, source, reason, orig
19
22
  // sign/spend/wallet/reveal need an audit sink: offer `kv` (namespace "audit") or they refuse
20
23
  ```
21
24
 
25
+ ## Untrusted code: labels, limits, concurrency
26
+
27
+ Code a model wrote is the usual reason to embed Synsema. These options make that safe to run
28
+ inside a server:
29
+
30
+ ```js
31
+ const syn = await Synsema.loadNode({ workers: 4 }); // async calls run on up to 4 Workers
32
+
33
+ const r = await syn.runAsync(code, {
34
+ host,
35
+ labels: true, // information-flow labels on
36
+ input: { var: "customer", value: row, sources: { card: ["customer"] } },
37
+ timeoutMs: 2000, // the Worker is stopped past this
38
+ maxSteps: 5_000_000, // interpreter steps, deterministic
39
+ maxMemoryMb: 64, // memory the run reserves
40
+ });
41
+ ```
42
+
43
+ - With `labels: true`, every key listed in `sources` reaches the program as a private value. A
44
+ private value, or a branch that depended on one, never reaches `print`, your `http` hook
45
+ (`fetch`/`http_*`), your `kv` (`state_*`, `remember`), your `llm` or your `sleep` (its number
46
+ crosses to the host): the call fails with `label_violation` before the hook runs.
47
+ `r.private_seen` says whether one went through.
48
+ - `timeoutMs` (async mode): `0` means no deadline; otherwise milliseconds, up to 2147483647,
49
+ counted from when the call starts running, not while it waits in the queue. Past it the call fails with a clear error and its Worker is replaced. The server's own
50
+ thread never blocks. A host hook with no deadline of its own that never answers keeps its Worker
51
+ busy unless the call has a `timeoutMs`.
52
+ - `maxSteps` and `maxMemoryMb` (both modes): the run ends with `ok: false` and an error that says
53
+ which limit it hit — never a trap. `maxSteps` counts interpreter steps from the start of the run
54
+ (in `handle`, of each request); `maxMemoryMb` counts the memory the run reserves, not what the
55
+ instance already held.
56
+ - Concurrent async calls wait in one queue and go to the first free Worker (up to `workers: n`). A
57
+ Worker that does not start in `startMs` (an option of `Synsema.load`/`loadNode`, default 30 s) is
58
+ stopped. In the sync API a trap
59
+ recreates the instance by itself.
60
+
22
61
  ## Lending capabilities (the host)
23
62
 
24
63
  The program keeps its manifest (`require net("api.example")`, `require llm`,
package/bridge.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp).
12
12
  // Node/Bun/Deno: funciona sin más.
13
13
 
14
- import { dispatchHostAsync } from "./index.mjs";
14
+ import { dispatchHostAsync, safeMsg, safeJson } from "./index.mjs";
15
15
 
16
16
  const te = new TextEncoder();
17
17
  const IS_NODE = typeof process !== "undefined" && process.versions && process.versions.node;
@@ -26,6 +26,7 @@ async function spawnWorker() {
26
26
  post: (m) => w.postMessage(m),
27
27
  onMessage: (fn) => w.on("message", fn),
28
28
  onError: (fn) => w.on("error", fn),
29
+ onExit: (fn) => w.on("exit", fn),
29
30
  terminate: () => w.terminate(),
30
31
  };
31
32
  }
@@ -33,12 +34,17 @@ async function spawnWorker() {
33
34
  return {
34
35
  post: (m) => w.postMessage(m),
35
36
  onMessage: (fn) => (w.onmessage = (e) => fn(e.data)),
36
- onError: (fn) => (w.onerror = (e) => fn(e.error ?? new Error(e.message))),
37
+ // Ronda 4 (B1): si el script del Worker no carga, `e.message` puede no estar.
38
+ onError: (fn) => (w.onerror = (e) => fn(e.error ?? new Error(e.message || "the worker script failed to load"))),
39
+ onExit: () => {},
37
40
  terminate: () => w.terminate(),
38
41
  };
39
42
  }
40
43
 
41
- export async function createBridge(module, { bridgeBytes = 4 * 1024 * 1024 } = {}) {
44
+ /** Cuánto puede tardar un Worker en arrancar antes de darlo por perdido (ronda 3, M9). */
45
+ const START_MS = 30_000;
46
+
47
+ export async function createBridge(module, { bridgeBytes = 4 * 1024 * 1024, signal, startMs = START_MS } = {}) {
42
48
  if (typeof SharedArrayBuffer === "undefined") {
43
49
  throw new Error(
44
50
  "synsema: the async mode needs SharedArrayBuffer (in browsers, serve the page cross-origin isolated: COOP same-origin + COEP require-corp); use the sync API with a sync host instead",
@@ -65,36 +71,64 @@ export async function createBridge(module, { bridgeBytes = 4 * 1024 * 1024 } = {
65
71
  if (remaining === 0) sending = null;
66
72
  }
67
73
 
68
- function reply(result) {
74
+ function reply(result, kind) {
69
75
  if (result === undefined) {
70
76
  Atomics.store(ctrl, 0, 3);
71
77
  Atomics.notify(ctrl, 0);
72
78
  return;
73
79
  }
74
- sending = { bytes: te.encode(JSON.stringify(result ?? null)), offset: 0 };
80
+ // Ronda 3 (M7): un hook que devuelve algo que no es JSON (un BigInt, un objeto circular) contesta
81
+ // un error; antes la excepción salía fuera del handler y el Worker quedaba esperando para siempre.
82
+ sending = { bytes: te.encode(safeJson(result, kind)), offset: 0 };
75
83
  writeChunk();
76
84
  }
77
85
 
86
+ let closed = false;
87
+ let started = false;
88
+ // v0.6.45 (ronda 2, M8): si el Worker muere (un error no atrapado, `exit`, el heap del Worker),
89
+ // el bridge queda cerrado: la llamada en curso falla con un error que NO se reintenta (pudo tener
90
+ // efectos) y ninguna llamada nueva va a un Worker muerto.
91
+ const died = (why) => {
92
+ if (closed) return;
93
+ closed = true;
94
+ const p = pending;
95
+ pending = null;
96
+ p?.reject(Object.assign(new Error(`synsema: the worker stopped while running the call (${why})`), { synsemaWorkerDied: true }));
97
+ // En el navegador `onerror` no termina el Worker (ronda 3, B1).
98
+ w.terminate();
99
+ };
78
100
  const ready = new Promise((resolve, reject) => {
79
101
  w.onError((e) => {
80
- const p = pending;
81
- pending = null;
82
- if (p) p.reject(e);
83
- else reject(e);
102
+ if (!started) return reject(e);
103
+ died(safeMsg(e));
104
+ });
105
+ w.onExit((code) => {
106
+ if (!started) return reject(new Error(`synsema: the worker exited before starting (code ${code})`));
107
+ died(`exit ${code}`);
84
108
  });
85
109
  w.onMessage(async (m) => {
86
110
  switch (m.type) {
87
111
  case "ready":
112
+ started = true;
88
113
  resolve();
89
114
  break;
115
+ case "init-error":
116
+ reject(new Error(`synsema: the worker could not instantiate the module: ${m.message}`));
117
+ break;
118
+ case "fatal":
119
+ died(m.message);
120
+ w.terminate();
121
+ break;
90
122
  case "host": {
123
+ // Ronda 4 (M6): el Worker espera esta respuesta en `Atomics.wait`; contesta SIEMPRE.
91
124
  let result;
92
125
  try {
93
126
  result = await dispatchHostAsync(pending?.host, m.kind, m.payload);
94
127
  } catch (e) {
95
- result = { error: String(e && e.message ? e.message : e) };
128
+ result = { error: safeMsg(e) };
129
+ } finally {
130
+ reply(result, m.kind);
96
131
  }
97
- reply(result);
98
132
  break;
99
133
  }
100
134
  case "ack":
@@ -115,20 +149,99 @@ export async function createBridge(module, { bridgeBytes = 4 * 1024 * 1024 } = {
115
149
  }
116
150
  });
117
151
  });
118
- w.post({ type: "init", module, sab });
119
- await ready;
152
+ // Ronda 3 (M8, M9): si el Worker no arranca (init-error, un `post` que falla, nunca llega `ready`,
153
+ // o `close()` lo aborta), se termina: no queda vivo ni retiene el proceso.
154
+ let startTimer;
155
+ let onAbort = null;
156
+ // `ready` puede rechazar DESPUÉS de que el plazo o el aborto ganaron la carrera (el Worker sale
157
+ // al terminarlo): queda manejada para que no sea un rechazo suelto.
158
+ ready.catch(() => {});
159
+ try {
160
+ if (signal?.aborted) throw new Error("synsema: closed while the worker was starting");
161
+ w.post({ type: "init", module, sab });
162
+ await Promise.race([
163
+ ready,
164
+ new Promise((_, reject) => {
165
+ startTimer = setTimeout(() => reject(new Error(`synsema: the worker did not start in ${startMs} ms`)), startMs);
166
+ onAbort = () => reject(new Error("synsema: closed while the worker was starting"));
167
+ signal?.addEventListener("abort", onAbort, { once: true });
168
+ }),
169
+ ]);
170
+ } catch (e) {
171
+ closed = true;
172
+ w.terminate();
173
+ throw e;
174
+ } finally {
175
+ clearTimeout(startTimer);
176
+ // Ronda 4 (M2): el listener se saca; si no, cada Worker reemplazado dejaba el suyo (y su SAB de
177
+ // 4 MB) colgado de la señal compartida hasta `close()`.
178
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
179
+ }
180
+
181
+ // v0.6.45 (D3): las llamadas a un mismo worker se ENCOLAN (antes la segunda fallaba con "one call
182
+ // at a time per instance": un servidor con dos requests a la vez se rompía). Para correr en
183
+ // paralelo, varios workers: `Synsema.load(src, { workers: n })`.
184
+ let tail = Promise.resolve();
185
+ let queued = 0;
186
+ // `synsemaWorkerClosed` = la llamada NO se llegó a mandar: quien llama la puede reintentar en otro
187
+ // Worker. La que estaba corriendo cuando se cerró recibe otro error (ronda 2, B1).
188
+ const closedError = () => Object.assign(new Error("synsema: the worker was closed"), { synsemaWorkerClosed: true });
189
+ function terminate() {
190
+ if (closed) return Promise.resolve();
191
+ closed = true;
192
+ const p = pending;
193
+ pending = null;
194
+ p?.reject(Object.assign(new Error("synsema: the worker was closed while running the call"), { synsemaWorkerDied: true }));
195
+ return w.terminate();
196
+ }
197
+ // v0.6.45 (D2) — el reloj de `timeoutMs` arranca acá, cuando la llamada empieza a correr (antes
198
+ // corría desde que entraba a la cola, y el timeout de una llamada encolada mataba a la que estaba
199
+ // corriendo). Al vencer se termina el Worker: las encoladas detrás reciben "worker closed" y
200
+ // quien llama las manda a otro.
201
+ function callNow(request, host, timeoutMs) {
202
+ if (closed) return Promise.reject(closedError());
203
+ return new Promise((resolve, reject) => {
204
+ let timer = null;
205
+ const done = (f) => (v) => {
206
+ if (timer) clearTimeout(timer);
207
+ f(v);
208
+ };
209
+ pending = { resolve: done(resolve), reject: done(reject), host };
210
+ if (timeoutMs > 0) {
211
+ timer = setTimeout(() => {
212
+ const p = pending;
213
+ pending = null;
214
+ p?.reject(new Error(`synsema: the call did not finish in ${timeoutMs} ms; its worker was stopped`));
215
+ terminate();
216
+ }, timeoutMs);
217
+ }
218
+ Atomics.store(ctrl, 0, 0);
219
+ w.post({ type: "call", request });
220
+ });
221
+ }
120
222
 
121
223
  return {
122
- call(request, host) {
123
- if (pending) return Promise.reject(new Error("synsema: one call at a time per instance"));
124
- return new Promise((resolve, reject) => {
125
- pending = { resolve, reject, host };
126
- Atomics.store(ctrl, 0, 0);
127
- w.post({ type: "call", request });
128
- });
224
+ call(request, host, timeoutMs = 0) {
225
+ queued++;
226
+ const run = () => callNow(request, host, timeoutMs);
227
+ const p = tail.then(run, run).finally(() => queued--);
228
+ tail = p.catch(() => {});
229
+ return p;
230
+ },
231
+ /** Llamadas en curso + encoladas (para elegir el worker menos ocupado). */
232
+ load() {
233
+ return queued;
234
+ },
235
+ /** ¿Tiene una llamada en curso o encolada? */
236
+ busy() {
237
+ return queued > 0;
238
+ },
239
+ /** Si el Worker ya se terminó (por un timeout o por `close`). */
240
+ closed() {
241
+ return closed;
129
242
  },
130
243
  async close() {
131
- await w.terminate();
244
+ await terminate();
132
245
  },
133
246
  };
134
247
  }
package/index.d.ts CHANGED
@@ -27,6 +27,12 @@ export interface KvStore {
27
27
  delete?(ns: string, key: string): void | Promise<void>;
28
28
  /** Claves del namespace (para `state_all`). */
29
29
  list?(ns: string): string[] | Promise<string[]>;
30
+ /**
31
+ * Comparar-y-cambiar atómico: escribe `value` sólo si el valor actual es exactamente `expected`
32
+ * (`null` = la clave no existe) y devuelve si escribió. Con esto `state_merge`/`state_cas` son
33
+ * atómicos aunque varias instancias compartan el KV; sin esto, esas dos lo dicen con un error.
34
+ */
35
+ cas?(ns: string, key: string, expected: string | null, value: string): boolean | Promise<boolean>;
30
36
  }
31
37
 
32
38
  export interface LlmResult {
@@ -57,6 +63,42 @@ export interface RunOptions {
57
63
  /** `"sandbox"` (stdout + time) o la lista de `--cap-set`: `"stdout,secret=ETH_*"`. */
58
64
  ceiling?: string;
59
65
  host?: Host;
66
+ /**
67
+ * v0.6.45 — etiquetas de flujo de información (`private`/`declassify`). Con `true`, un valor
68
+ * privado (o una rama que dependió de uno) no llega a ningún sumidero: ni `print`, ni el hook
69
+ * `http` (`fetch`/`http_*`), ni el `kv` (`state_*`, `remember`), ni el `llm`. Es lo que hay que
70
+ * encender para correr código que escribió un modelo sobre datos de un usuario.
71
+ */
72
+ labels?: boolean;
73
+ /** Nombre de una variable global cuyo valor final vuelve en `RunResult.result` CON sus etiquetas. */
74
+ result?: string;
75
+ /**
76
+ * La entrada del host, ligada como variable global antes de correr. Cada clave de `sources`
77
+ * presente llega al programa como `private(valor, principales)`. Exige `labels: true`.
78
+ */
79
+ input?: { var: string; value: unknown; sources?: Record<string, string[]> };
80
+ /**
81
+ * Sólo en el modo asíncrono: si la llamada no termina en este plazo — contado desde que EMPIEZA a
82
+ * correr, no desde que entró a la cola —, su Worker se termina y la Promise falla con un error
83
+ * claro (el código no confiable nunca bloquea el hilo del servidor). Un hook del host sin plazo
84
+ * propio que nunca contesta ocupa su Worker si la llamada no tiene `timeoutMs`. `0` = sin plazo;
85
+ * si no, un número finito de milisegundos, como mucho 2147483647.
86
+ */
87
+ timeoutMs?: number;
88
+ /**
89
+ * Tope de pasos del intérprete (los de `steps()`, deterministas), contados desde que empieza la
90
+ * corrida (en `handle`, cada request). Al pasarlo la corrida termina con `ok: false` y un error
91
+ * que lo dice, no con un trap. Sirve en los dos modos. Un entero positivo.
92
+ */
93
+ maxSteps?: number;
94
+ /**
95
+ * Tope en MB de la memoria que RESERVA la corrida (los bytes vivos que cuenta el allocator del
96
+ * módulo desde que empieza, no lo que la instancia ya tenía). La reserva que lo pasaría se
97
+ * rechaza y la corrida termina con `ok: false` y "memory limit reached" (en `handle`, un 500).
98
+ * Cuenta bytes pedidos: la memoria del módulo puede crecer hasta ~2× eso y no se devuelve.
99
+ * Igual que `maxSteps`. Un entero positivo.
100
+ */
101
+ maxMemoryMb?: number;
60
102
  }
61
103
 
62
104
  export interface AuditEntry {
@@ -77,6 +119,12 @@ export interface RunResult {
77
119
  llm_tokens: number;
78
120
  /** v0.6.42 — el código de salida: el de `exit(code)` si el programa lo pidió, si no 0 (ok) o 1. */
79
121
  exit: number;
122
+ /** Con `result`: el valor final de esa variable (las partes privadas como `{"$private": [...]}`). */
123
+ result?: unknown;
124
+ /** Con `labels`: cada `declassify` que corrió (quién, qué principal, dónde). */
125
+ declassify?: unknown[];
126
+ /** Con `labels`: si algún valor privado pasó por la corrida (entonces `steps` no se publica). */
127
+ private_seen?: boolean;
80
128
  }
81
129
 
82
130
  export interface TestResult {
@@ -125,20 +173,50 @@ export declare function instantiate(
125
173
  hostCall: (kind: string, payload: any) => any,
126
174
  ): Promise<{ exports: WebAssembly.Exports; call(request: object): any }>;
127
175
 
176
+ export interface LoadOptions {
177
+ /** Cuántos Workers usa el modo asíncrono (default 1). Con más, `runAsync` corre en paralelo. */
178
+ workers?: number;
179
+ /**
180
+ * Cuánto puede tardar un Worker en arrancar antes de darlo por perdido (ms, default 30 000; entre 1
181
+ * y 2147483647). Opción de `Synsema.load`/`loadNode`.
182
+ */
183
+ startMs?: number;
184
+ }
185
+
186
+ export declare function instantiateSync(
187
+ module: WebAssembly.Module,
188
+ hostCall: (kind: string, payload: any) => any,
189
+ ): { exports: WebAssembly.Exports; call(request: object): any };
190
+
128
191
  export declare class Synsema {
129
- constructor(module: WebAssembly.Module);
130
- static load(src: ModuleSource): Promise<Synsema>;
131
- /** Prepara la instancia síncrona (obligatorio antes de run/test/check/handle). */
192
+ constructor(module: WebAssembly.Module, opts?: LoadOptions);
193
+ static load(src: ModuleSource, opts?: LoadOptions): Promise<Synsema>;
194
+ /**
195
+ * Node: carga el `synsema.wasm` que trae el paquete (sin resolver rutas; anda en Next, rutas
196
+ * `/api` y Server Actions).
197
+ */
198
+ static loadNode(opts?: LoadOptions): Promise<Synsema>;
199
+ /**
200
+ * Prepara la instancia síncrona (obligatorio antes de run/test/check/handle). Después de un trap
201
+ * la instancia se recrea sola; no hace falta volver a llamarlo.
202
+ */
132
203
  ready(): Promise<this>;
133
204
  version(): string;
134
205
  run(source: string, opts?: RunOptions): RunResult;
135
206
  test(source: string, opts?: RunOptions): TestResult;
136
207
  check(source: string, opts?: { filename?: string }): CheckResult;
137
208
  handle(source: string, request: HandleRequest, opts?: RunOptions): HandleResult;
138
- /** Modo asíncrono (Worker + Atomics): los hooks del host pueden ser async. */
209
+ /**
210
+ * Modo asíncrono (Worker + Atomics): los hooks del host pueden ser async. Las llamadas esperan en
211
+ * una sola cola y van al primer Worker libre (hasta `workers`).
212
+ */
139
213
  runAsync(source: string, opts?: RunOptions): Promise<RunResult>;
140
214
  testAsync(source: string, opts?: RunOptions): Promise<TestResult>;
141
215
  handleAsync(source: string, request: HandleRequest, opts?: RunOptions): Promise<HandleResult>;
216
+ /**
217
+ * Termina los Workers del modo asíncrono. Es final: lo que espera en la cola falla con "closed" y
218
+ * una llamada asíncrona posterior también (la API síncrona sigue andando).
219
+ */
142
220
  close(): Promise<void>;
143
221
  }
144
222
 
package/index.mjs CHANGED
@@ -54,16 +54,44 @@ export function offersOf(host) {
54
54
  const o = {};
55
55
  if (typeof host.http === "function") o.http = true;
56
56
  if (host.kv && typeof host.kv.get === "function") o.kv = true;
57
+ // v0.6.45: comparar-y-cambiar, lo que hace atómicos state_merge/state_cas con un KV compartido.
58
+ if (host.kv && typeof host.kv.cas === "function") o.kv_cas = true;
57
59
  if (typeof host.llm === "function") o.llm = true;
58
60
  if (typeof host.log === "function") o.log = true;
59
61
  if (typeof host.sleep === "function" || host.sleep === true) o.sleep = true;
60
62
  return o;
61
63
  }
62
64
 
65
+ /**
66
+ * v0.6.45 (ronda 4, M6) — el texto de un error que NUNCA lanza: un hook puede lanzar
67
+ * `Object.create(null)` o un objeto cuyo `message` es un getter que lanza, y un `String(e)` lanzaba
68
+ * fuera del manejo (el proceso caía o el Worker quedaba esperando para siempre).
69
+ */
70
+ export function safeMsg(e) {
71
+ try {
72
+ if (e && typeof e.message === "string") return e.message;
73
+ return String(e);
74
+ } catch {
75
+ return "the host hook threw a value that cannot be shown";
76
+ }
77
+ }
78
+
79
+ /** `JSON.stringify` de la respuesta de un hook, o un `{error}` si no es JSON (un BigInt, un ciclo). */
80
+ export function safeJson(result, kind) {
81
+ try {
82
+ return JSON.stringify(result ?? null);
83
+ } catch (e) {
84
+ return JSON.stringify({ error: `host ${kind} returned a non-JSON value: ${safeMsg(e)}` });
85
+ }
86
+ }
87
+
63
88
  /** Un hook async usado desde la API síncrona devolvería una Promise donde el intérprete
64
89
  * espera un valor: en vez de un `{status: 0}` mudo, un error que nombra el fix. */
65
90
  function syncOnly(kind, r) {
66
91
  if (r && typeof r.then === "function") {
92
+ // Ronda 3 (M9): la Promise queda con un handler; si no, al rechazar tiraba el proceso
93
+ // (unhandledRejection) después de que la corrida ya había devuelto.
94
+ r.then(undefined, () => {});
67
95
  throw new Error(`synsema: the host \`${kind}\` hook returned a Promise — use runAsync/handleAsync (Worker + Atomics) or make the hook synchronous`);
68
96
  }
69
97
  return r;
@@ -89,15 +117,19 @@ export function dispatchHostSync(host, kind, payload) {
89
117
  return {};
90
118
  case "kv_list":
91
119
  return host.kv ? { keys: host.kv.list ? syncOnly("kv.list", host.kv.list(payload.ns)) : [] } : undefined;
120
+ case "kv_cas":
121
+ return host.kv && typeof host.kv.cas === "function"
122
+ ? casReply(syncOnly("kv.cas", host.kv.cas(payload.ns, payload.key, payload.expected ?? null, payload.value)))
123
+ : undefined;
92
124
  case "llm":
93
125
  return typeof host.llm === "function" ? normalizeLlm(syncOnly("llm", host.llm(payload.op, payload.prompt))) : undefined;
94
126
  case "log":
95
127
  if (typeof host.log !== "function") return undefined;
96
- host.log(payload.line);
128
+ syncOnly("log", host.log(payload.line));
97
129
  return {};
98
130
  case "sleep":
99
131
  if (typeof host.sleep === "function") {
100
- host.sleep(payload.secs);
132
+ syncOnly("sleep", host.sleep(payload.secs));
101
133
  return {};
102
134
  }
103
135
  if (host.sleep === true) {
@@ -128,6 +160,10 @@ export async function dispatchHostAsync(host, kind, payload) {
128
160
  return {};
129
161
  case "kv_list":
130
162
  return host.kv ? { keys: host.kv.list ? await host.kv.list(payload.ns) : [] } : undefined;
163
+ case "kv_cas":
164
+ return host.kv && typeof host.kv.cas === "function"
165
+ ? casReply(await host.kv.cas(payload.ns, payload.key, payload.expected ?? null, payload.value))
166
+ : undefined;
131
167
  case "llm":
132
168
  return typeof host.llm === "function" ? normalizeLlm(await host.llm(payload.op, payload.prompt)) : undefined;
133
169
  case "log":
@@ -194,6 +230,23 @@ export function sleepSync(secs) {
194
230
  * (el bridge async lo implementa con Atomics). Devuelve `{ call(request) -> response }`.
195
231
  */
196
232
  export async function instantiate(module, hostCall) {
233
+ const { imports, bind } = hostImports(hostCall);
234
+ const instance = await WebAssembly.instantiate(module, imports);
235
+ return bind(instance.exports);
236
+ }
237
+
238
+ /**
239
+ * v0.6.45 (D4) — lo mismo, SÍNCRONO (`new WebAssembly.Instance`): con el `Module` ya compilado es
240
+ * barato, y es lo que deja a la API síncrona recrear la instancia después de un trap sin pedirle
241
+ * al usuario un `await syn.ready()`. (En el main thread de un navegador puede estar acotado por
242
+ * tamaño; ahí se cae al camino de siempre.)
243
+ */
244
+ export function instantiateSync(module, hostCall) {
245
+ const { imports, bind } = hostImports(hostCall);
246
+ return bind(new WebAssembly.Instance(module, imports).exports);
247
+ }
248
+
249
+ function hostImports(hostCall) {
197
250
  let exports = null;
198
251
  const mem = () => new Uint8Array(exports.memory.buffer);
199
252
  const writeBytes = (bytes) => {
@@ -210,10 +263,12 @@ export async function instantiate(module, hostCall) {
210
263
  try {
211
264
  result = hostCall(kind, payload);
212
265
  } catch (e) {
213
- result = { error: String(e && e.message ? e.message : e) };
266
+ result = { error: safeMsg(e) };
214
267
  }
215
268
  if (result === undefined) return 0;
216
- const bytes = te.encode(JSON.stringify(result ?? null));
269
+ // Ronda 4 (B1): un valor que no es JSON vuelve como error del hook (antes hacía trapear el
270
+ // módulo y se perdía la corrida).
271
+ const bytes = te.encode(safeJson(result, kind));
217
272
  const ptr = exports.synsema_alloc(4 + bytes.length);
218
273
  const m = mem();
219
274
  new DataView(m.buffer).setUint32(ptr, bytes.length, true);
@@ -233,8 +288,14 @@ export async function instantiate(module, hostCall) {
233
288
  },
234
289
  },
235
290
  };
236
- const instance = await WebAssembly.instantiate(module, imports);
237
- exports = instance.exports;
291
+ const bind = (ex) => {
292
+ exports = ex;
293
+ return callable(ex, mem, writeBytes);
294
+ };
295
+ return { imports, bind };
296
+ }
297
+
298
+ function callable(exports, mem, writeBytes) {
238
299
  return {
239
300
  exports,
240
301
  call(request) {
@@ -243,8 +304,20 @@ export async function instantiate(module, hostCall) {
243
304
  let out;
244
305
  try {
245
306
  out = exports.synsema_call(rp, req.length);
307
+ } catch (e) {
308
+ // v0.6.45 (D2): el allocator del módulo rechazó una reserva por `maxMemoryMb` y Rust hizo
309
+ // trap. La instancia igual se descarta (lo hace quien llama), pero la corrida no "trapeó":
310
+ // se pasó del tope que el host pidió, y eso vuelve como un error de la corrida.
311
+ if (typeof exports.synsema_limit_hit === "function" && exports.synsema_limit_hit() === 1) {
312
+ e.synsemaResult = limitResult(request);
313
+ }
314
+ throw e;
246
315
  } finally {
247
- exports.synsema_free(rp, req.length);
316
+ try {
317
+ exports.synsema_free(rp, req.length);
318
+ } catch {
319
+ // instancia rota por el trap: se descarta entera
320
+ }
248
321
  }
249
322
  const m = mem();
250
323
  const len = new DataView(m.buffer).getUint32(out, true);
@@ -255,11 +328,48 @@ export async function instantiate(module, hostCall) {
255
328
  };
256
329
  }
257
330
 
331
+ const MEMORY_LIMIT = "Runtime error: memory limit reached: the run reserved more than maxMemoryMb";
332
+
333
+ /** El resultado de una corrida cortada por `maxMemoryMb`, con la forma de su `op`. */
334
+ function limitResult(request) {
335
+ if (request.op === "handle") {
336
+ return {
337
+ status: 500,
338
+ content_type: "application/json",
339
+ headers: [["content-type", "application/json"]],
340
+ body: JSON.stringify({ error: "memory limit reached", status: 500 }),
341
+ log: [],
342
+ errors: [MEMORY_LIMIT],
343
+ audit: [],
344
+ };
345
+ }
346
+ if (request.op === "test") return { ok: false, passed: 0, failed: 0, lines: [MEMORY_LIMIT], audit: [] };
347
+ return { ok: false, output: [], errors: [MEMORY_LIMIT], audit: [], llm_tokens: 0, exit: 1 };
348
+ }
349
+
350
+ /**
351
+ * v0.6.45 (ronda 2, B2): `kv.cas` del host tiene que contestar `true` o `false`. Un `cas` sin
352
+ * `return` (undefined) contaba como `false` y el motor reintentaba 64 veces hasta "kept changing".
353
+ */
354
+ function casReply(v) {
355
+ return typeof v === "boolean" ? { swapped: v } : { error: `kv.cas must return true or false, got ${v === undefined ? "undefined" : JSON.stringify(v)}` };
356
+ }
357
+
258
358
  function requestOf(op, source, opts = {}) {
259
359
  const r = { op, source, filename: opts.filename ?? "<embedded>" };
260
360
  if (opts.env) r.env = opts.env;
261
361
  if (opts.ceiling) r.ceiling = opts.ceiling;
262
362
  if (opts.host) r.host = offersOf(opts.host);
363
+ // v0.6.45 (D1): etiquetas de flujo. Antes no se reenviaban y el motor las tenía apagadas aunque
364
+ // el host las pidiera.
365
+ if (opts.labels) r.labels = true;
366
+ if (opts.result) r.result = opts.result;
367
+ if (opts.input) r.input = { var: opts.input.var, value: opts.input.value, sources: opts.input.sources ?? {} };
368
+ // v0.6.45 (D2): límites de la corrida (los mide el motor: pasos deterministas y la memoria del módulo).
369
+ // `!= null`: un 0 (o un valor inválido) llega al motor, que lo rechaza con un error claro; antes
370
+ // se descartaba por falsy y la corrida quedaba sin límite.
371
+ if (opts.maxSteps != null) r.max_steps = opts.maxSteps;
372
+ if (opts.maxMemoryMb != null) r.max_memory_mb = opts.maxMemoryMb;
263
373
  return r;
264
374
  }
265
375
 
@@ -277,15 +387,54 @@ function normalizeRequest(request) {
277
387
  }
278
388
 
279
389
  export class Synsema {
280
- /** @param {WebAssembly.Module} module */
281
- constructor(module) {
390
+ /**
391
+ * @param {WebAssembly.Module} module
392
+ * @param {{workers?: number}} [opts] `workers`: cuántos Workers usa el modo asíncrono (default 1).
393
+ */
394
+ constructor(module, opts = {}) {
282
395
  this.module = module;
283
396
  this._inst = null;
397
+ this._workers = Math.max(1, opts.workers | 0 || 1);
398
+ // Ronda 4 (B1): cuánto puede tardar un Worker en arrancar (default 30 s). Validado como
399
+ // `timeoutMs`: un valor fuera del rango de los timers vencía en 1 ms y ningún Worker arrancaba.
400
+ if (opts.startMs != null && !(Number.isFinite(opts.startMs) && opts.startMs > 0 && opts.startMs <= 2147483647)) {
401
+ throw new Error("synsema: startMs must be a number of milliseconds between 1 and 2147483647");
402
+ }
403
+ this._startMs = opts.startMs;
404
+ this._bridges = [];
284
405
  }
285
406
 
286
407
  /** Carga el .wasm (bytes, Module, URL/path, Response). */
287
- static async load(src) {
288
- return new Synsema(await readModule(src));
408
+ static async load(src, opts = {}) {
409
+ return new Synsema(await readModule(src), opts);
410
+ }
411
+
412
+ /**
413
+ * v0.6.45 (D5) — Node: carga el `synsema.wasm` que viene en el paquete, sin que el que lo usa
414
+ * resuelva la ruta. Lo lee del directorio de este archivo y, si un bundler lo movió (Turbopack,
415
+ * webpack), de `node_modules/@synsema/wasm`: anda en un script, en una ruta `/api` y en una
416
+ * Server Action de Next.
417
+ */
418
+ static async loadNode(opts = {}) {
419
+ const { readFile } = await import("node:fs/promises");
420
+ const { fileURLToPath } = await import("node:url");
421
+ const { dirname, join } = await import("node:path");
422
+ const candidates = [];
423
+ try {
424
+ candidates.push(join(dirname(fileURLToPath(import.meta.url)), "synsema.wasm"));
425
+ } catch {
426
+ // import.meta.url reescrito a algo que no es file:
427
+ }
428
+ candidates.push(join(process.cwd(), "node_modules", "@synsema", "wasm", "synsema.wasm"));
429
+ let lastErr;
430
+ for (const p of candidates) {
431
+ try {
432
+ return new Synsema(await WebAssembly.compile(await readFile(p)), opts);
433
+ } catch (e) {
434
+ lastErr = e;
435
+ }
436
+ }
437
+ throw new Error(`synsema: cannot find synsema.wasm (tried ${candidates.join(", ")}): ${lastErr && lastErr.message}`);
289
438
  }
290
439
 
291
440
  async _instance() {
@@ -304,10 +453,20 @@ export class Synsema {
304
453
  try {
305
454
  return inst.call(request);
306
455
  } catch (e) {
307
- // Un trap deja la instancia inservible: se reinstancia en la próxima llamada.
456
+ // Un trap deja la instancia inservible: se recrea ya (sincrónico, con el Module compilado),
457
+ // así la próxima llamada anda sin `await syn.ready()` (v0.6.45, D4). Si el host no deja
458
+ // instanciar sincrónico, queda descartada y `ready()` la recrea.
308
459
  this._inst = null;
309
460
  this._ready = null;
310
- throw new Error(`synsema: the module trapped (${e && e.message ? e.message : e}); the instance was discarded`);
461
+ try {
462
+ const self = this;
463
+ this._inst = instantiateSync(this.module, (kind, payload) => dispatchHostSync(self._host, kind, payload));
464
+ this._ready = Promise.resolve(this._inst);
465
+ } catch {
466
+ // sin instancia: la próxima llamada pide `ready()`
467
+ }
468
+ if (e && e.synsemaResult) return e.synsemaResult;
469
+ throw new Error(`synsema: the module trapped (${safeMsg(e)}); the instance was recreated`);
311
470
  } finally {
312
471
  this._host = null;
313
472
  }
@@ -343,28 +502,129 @@ export class Synsema {
343
502
 
344
503
  // ---- modo asíncrono (Worker + Atomics) ----
345
504
 
346
- async _bridge() {
347
- if (!this._worker) {
348
- const { createBridge } = await import("./bridge.mjs");
349
- this._worker = await createBridge(this.module);
505
+ /**
506
+ * v0.6.45 (ronda 2, M8) — una cola CENTRAL: cada llamada asíncrona entra acá y `_pump()` (sin
507
+ * `await`, así no hay carreras) la entrega a un Worker libre, o crea uno mientras no se llegó a
508
+ * `workers`. Un Worker corre una llamada a la vez. Antes cada llamada elegía Worker por su cuenta
509
+ * después de un `await`: una ráfaga de 4 con `workers: 2` quedaba toda en uno.
510
+ */
511
+ _pump() {
512
+ this._queue = this._queue ?? [];
513
+ this._bridges = (this._bridges ?? []).filter((b) => !b.closed());
514
+ this._spawning = this._spawning ?? 0;
515
+ while (this._queue.length > 0 && !this._closed) {
516
+ const idle = this._bridges.find((b) => !b.busy());
517
+ if (idle) {
518
+ this._dispatch(idle, this._queue.shift());
519
+ continue;
520
+ }
521
+ if (this._bridges.length + this._spawning < this._workers) {
522
+ // Ronda 4 (B1): después de un arranque fallido con Workers sanos, no se reintenta antes de un
523
+ // segundo (cada intento instancia el .wasm entero, y el motivo suele ser falta de memoria).
524
+ // El timer se arma ACÁ, por lo que falta (revisión H1: armado sólo al fallar, un segundo fallo
525
+ // o un timer que vencía unos ms antes dejaban la cola sin nadie que la despertara). Sin
526
+ // Workers sanos no se espera: o arranca uno, o la cola falla.
527
+ const wait = (this._spawnRetryAt ?? 0) - performance.now();
528
+ if (wait > 0 && this._bridges.length > 0) {
529
+ if (!this._retryTimer) {
530
+ this._retryTimer = setTimeout(() => {
531
+ this._retryTimer = null;
532
+ this._pump();
533
+ }, Math.ceil(wait) + 1);
534
+ if (typeof this._retryTimer.unref === "function") this._retryTimer.unref();
535
+ }
536
+ break;
537
+ }
538
+ this._spawning++;
539
+ this._spawnAbort = this._spawnAbort ?? new AbortController();
540
+ const signal = this._spawnAbort.signal;
541
+ import("./bridge.mjs")
542
+ .then(({ createBridge }) => createBridge(this.module, { signal, startMs: this._startMs }))
543
+ .then(
544
+ (b) => {
545
+ this._spawning--;
546
+ // `close()` llegó mientras nacía: se cierra, no queda vivo.
547
+ if (this._closed) return b.close();
548
+ this._bridges.push(b);
549
+ this._pump();
550
+ },
551
+ (err) => {
552
+ this._spawning--;
553
+ // No se pudo crear un Worker (ronda 4, revisión: sin esto, con `workers` ≥ 2 el arranque
554
+ // fallido se reintentaba sin pausa y la cola no fallaba nunca). Tres casos:
555
+ // - hay Workers sanos: lo encolado sigue con ellos y el próximo arranque espera un
556
+ // segundo (un solo timer, que no retiene el proceso);
557
+ // - no hay sanos pero otro arranque está en vuelo: se espera a ése;
558
+ // - no queda ninguno: lo encolado falla con esta causa, sin colgarse.
559
+ if (this._bridges.filter((b) => !b.closed()).length > 0) {
560
+ this._spawnRetryAt = performance.now() + 1000;
561
+ return this._pump(); // arma el timer de la espera
562
+ }
563
+ if (this._spawning > 0) return;
564
+ const q = this._queue.splice(0);
565
+ for (const it of q) it.reject(err);
566
+ },
567
+ );
568
+ }
569
+ break;
570
+ }
571
+ }
572
+
573
+ _dispatch(b, it) {
574
+ b.call(it.request, it.host, it.timeoutMs).then(it.resolve, (e) => {
575
+ // No se llegó a mandar (el Worker se cerró antes): vuelve al principio de la cola. Lo que sí
576
+ // corrió (un timeout, un Worker que murió en el medio) NO se reintenta: pudo tener efectos.
577
+ if (e && e.synsemaWorkerClosed && !this._closed) {
578
+ this._queue.unshift(it);
579
+ } else {
580
+ it.reject(e);
581
+ }
582
+ }).finally(() => this._pump());
583
+ }
584
+
585
+ /**
586
+ * v0.6.45 (D2) — `timeoutMs`: si la llamada no terminó en ese plazo (contado desde que EMPIEZA
587
+ * a correr, no desde que entró a la cola), su Worker se termina (el código corría en otro hilo:
588
+ * el del servidor nunca se bloquea) y la llamada falla con un error claro. Los hooks del host que
589
+ * ya estaban en curso (un `fetch`) no se cortan: terminan solos y su resultado se descarta.
590
+ */
591
+ _callAsync(request, opts) {
592
+ if (this._closed) return Promise.reject(new Error("synsema: closed (close() is final; load a new instance)"));
593
+ // Ronda 3 (B1): un plazo fuera del rango de los timers (o `Infinity`) vencía en 1 ms. `0` sigue
594
+ // siendo "sin plazo", como antes (ronda 4).
595
+ if (opts.timeoutMs != null && !(Number.isFinite(opts.timeoutMs) && opts.timeoutMs >= 0 && opts.timeoutMs <= 2147483647)) {
596
+ return Promise.reject(new Error("synsema: timeoutMs must be 0 (no deadline) or a number of milliseconds up to 2147483647"));
350
597
  }
351
- return this._worker;
598
+ return new Promise((resolve, reject) => {
599
+ this._queue = this._queue ?? [];
600
+ this._queue.push({ request, host: opts.host, timeoutMs: opts.timeoutMs > 0 ? opts.timeoutMs : 0, resolve, reject });
601
+ this._pump();
602
+ });
352
603
  }
353
604
  async runAsync(source, opts = {}) {
354
- return (await this._bridge()).call(requestOf("run", source, opts), opts.host);
605
+ return this._callAsync(requestOf("run", source, opts), opts);
355
606
  }
356
607
  async testAsync(source, opts = {}) {
357
- return (await this._bridge()).call(requestOf("test", source, opts), opts.host);
608
+ return this._callAsync(requestOf("test", source, opts), opts);
358
609
  }
359
610
  async handleAsync(source, request, opts = {}) {
360
- return (await this._bridge()).call({ ...requestOf("handle", source, opts), request: normalizeRequest(request) }, opts.host);
611
+ return this._callAsync({ ...requestOf("handle", source, opts), request: normalizeRequest(request) }, opts);
361
612
  }
362
- /** Termina el Worker del modo asíncrono (si se creó). */
613
+ /**
614
+ * Termina los Workers del modo asíncrono. Es FINAL (ronda 2, B1): lo que esperaba en la cola falla
615
+ * con "closed", un Worker que estaba naciendo se cierra al nacer, y una llamada asíncrona posterior
616
+ * falla. La API síncrona sigue andando.
617
+ */
363
618
  async close() {
364
- if (this._worker) {
365
- await this._worker.close();
366
- this._worker = null;
367
- }
619
+ this._closed = true;
620
+ if (this._retryTimer) clearTimeout(this._retryTimer);
621
+ // Los Workers que están naciendo se abortan (ronda 3, M9).
622
+ this._spawnAbort?.abort();
623
+ const q = (this._queue ?? []).splice(0);
624
+ for (const it of q) it.reject(new Error("synsema: closed (close() is final; load a new instance)"));
625
+ const bs = this._bridges ?? [];
626
+ this._bridges = [];
627
+ await Promise.all(bs.map((b) => b.close()));
368
628
  }
369
629
  }
370
630
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synsema/wasm",
3
- "version": "0.6.44",
3
+ "version": "0.6.45",
4
4
  "description": "Synsema — the programming language for AI agents — embedded as WebAssembly: run .syn programs inside browsers, Node/Bun/Deno and edge runtimes, lending them http/kv/llm from your app",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/synsema.wasm CHANGED
Binary file
package/worker.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // y en el navegador (module Worker). Instancia el .wasm con un hostCall que manda el
3
3
  // pedido al main thread y se bloquea con Atomics.wait hasta la respuesta.
4
4
 
5
- import { instantiate } from "./index.mjs";
5
+ import { instantiate, safeMsg } from "./index.mjs";
6
6
 
7
7
  const td = new TextDecoder();
8
8
  const DATA_OFFSET = 16;
@@ -60,7 +60,14 @@ onMessage(async (m) => {
60
60
  module = m.module;
61
61
  ctrl = new Int32Array(m.sab, 0, 4);
62
62
  data = new Uint8Array(m.sab, DATA_OFFSET);
63
- inst = await instantiate(module, hostCall);
63
+ // v0.6.45 (ronda 2, M8): un fallo al instanciar se avisa (en el navegador no dispara `onerror`
64
+ // y el main esperaba `ready` para siempre).
65
+ try {
66
+ inst = await instantiate(module, hostCall);
67
+ } catch (e) {
68
+ post({ type: "init-error", message: safeMsg(e) });
69
+ return;
70
+ }
64
71
  post({ type: "ready" });
65
72
  return;
66
73
  }
@@ -69,8 +76,16 @@ onMessage(async (m) => {
69
76
  const response = inst.call(m.request);
70
77
  post({ type: "result", response });
71
78
  } catch (e) {
72
- post({ type: "error", message: `synsema: the module trapped (${e && e.message ? e.message : e}); the instance was recreated` });
73
- inst = await instantiate(module, hostCall);
79
+ // v0.6.45: la instancia nueva se arma ANTES de avisar, así la próxima llamada (que el main
80
+ // manda al recibir la respuesta) no entra mientras se instancia.
81
+ try {
82
+ inst = await instantiate(module, hostCall);
83
+ } catch (e2) {
84
+ post({ type: "fatal", message: `the module trapped and could not be instantiated again: ${safeMsg(e2)}` });
85
+ return;
86
+ }
87
+ if (e && e.synsemaResult) post({ type: "result", response: e.synsemaResult });
88
+ else post({ type: "error", message: `synsema: the module trapped (${safeMsg(e)}); the instance was recreated` });
74
89
  }
75
90
  }
76
91
  });