@synsema/wasm 0.6.44 → 0.6.46

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,54 @@ 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
+ result: "out", // r.result = the final value of `out`
38
+ timeoutMs: 2000, // the Worker is stopped past this
39
+ maxSteps: 5_000_000, // interpreter steps, deterministic
40
+ maxMemoryMb: 64, // memory the run reserves
41
+ });
42
+ ```
43
+
44
+ - With `labels: true`, every key listed in `sources` reaches the program as a private value. A
45
+ private value, or a branch that depended on one, never reaches `print`, your `http` hook
46
+ (`fetch`/`http_*`), your `kv` (`state_*`, `remember`), your `llm` or your `sleep` (its number
47
+ crosses to the host): the call fails with `label_violation` before the hook runs.
48
+ `r.private_seen` says whether one went through; then `r.steps` is `null` (how many steps the
49
+ run took depended on the data). `sources` needs `labels: true`.
50
+ - `result: "out"` returns the final value of the global `out` in `r.result`, with its labels: a
51
+ private part comes back as `{"$private": ["customer"], "value": …}`. `r.declassify` lists each
52
+ `declassify` the run made. Without `labels`, `result` is plain JSON.
53
+ - `timeoutMs` (async mode): `0` means no deadline; otherwise milliseconds, up to 2147483647,
54
+ counted from when the call starts running, not while it waits in the queue. Past it the call
55
+ fails with a clear error and its Worker is replaced. The server's own thread never blocks. A
56
+ host hook already running (a `fetch`) is not cut; its result is discarded. A host hook with no
57
+ deadline of its own that never answers keeps its Worker busy unless the call has a `timeoutMs`.
58
+ - `maxSteps` and `maxMemoryMb` (both modes): the run ends with `ok: false` and an error that says
59
+ which limit it hit — never a trap. `maxSteps` counts interpreter steps from the start of the run
60
+ (in `handle`, of each request); `maxMemoryMb` counts the memory the run reserves, not what the
61
+ instance already held. Both are positive integers: `0` is an error, not "no limit". No call
62
+ inherits the limits of the previous one.
63
+ - Concurrent async calls wait in one queue and go to the first free Worker (up to `workers: n`). A
64
+ Worker that does not start in `startMs` (an option of `Synsema.load`/`loadNode`, default 30 s) is
65
+ stopped. A Worker that dies fails the call it was running (not retried: it may have had
66
+ effects) and is replaced. `close()` is final: what waits fails with "closed".
67
+ - A host hook that returns something that is not JSON (a `BigInt`, a circular object) or throws a
68
+ value without a message counts as that hook failing in that call, in both APIs — not a hung
69
+ Worker or a trapped module.
70
+ - In the sync API a trap recreates the instance by itself (the call throws
71
+ `synsema: the module trapped (…); the instance was recreated`; the next call works).
72
+
22
73
  ## Lending capabilities (the host)
23
74
 
24
75
  The program keeps its manifest (`require net("api.example")`, `require llm`,
@@ -38,7 +89,25 @@ syn.run(`require llm\nprint(reason about "the weather")`, { host, ceiling: "stdo
38
89
  ```
39
90
 
40
91
  - `kv` backs persistent agent memory (`remember`/`recall`, rules, progress — namespace
41
- `memory:<declared name>`) and `state_*` (namespace `state`).
92
+ `memory:<declared name>`) and `state_*` (namespace `state`). `delete` and `list` are optional.
93
+ - `kv.cas(ns, key, expected, value)` (optional) is compare-and-swap: write `value` only if the
94
+ stored text is exactly `expected` (`null` = the key does not exist) and return `true` or
95
+ `false` — anything else fails the call with `kv.cas must return true or false`. Your store must
96
+ compare and write in one step (a transaction, a conditional put):
97
+
98
+ ```js
99
+ cas: (ns, k, expected, v) => {
100
+ if ((store.get(ns + "/" + k) ?? null) !== expected) return false;
101
+ store.set(ns + "/" + k, v);
102
+ return true;
103
+ },
104
+ ```
105
+
106
+ `state_merge(key, map)` and `state_cas(key, expected, new)` depend on it. With a host `kv`
107
+ that has `cas` they are atomic, also across instances sharing the store. With a host `kv`
108
+ without `cas` they fail saying so (`the host's kv does not offer cas (kv.cas) …`) instead of
109
+ risking a lost write. With no host `kv`, the state belongs to the wasm instance and is atomic:
110
+ it lasts across that instance's `handle` requests, and each `run` starts with an empty state.
42
111
  - `llm` backs `reason`/`decide`/`analyze`/`generate`; `llm_usage()` sums the tokens you report.
43
112
  - `http` backs `fetch`/`http_*` and the blockchain read-side RPC — after the `net(host)`
44
113
  gate, with the same URL canonicalization as the native binary.
@@ -81,8 +150,12 @@ validates without running; `syn.version()` is the artifact's release tag.
81
150
  ## Files, time, randomness
82
151
 
83
152
  This artifact has **no filesystem** (`read_file` says so; pass data through `env` or the
84
- source). `now()` comes from `Date.now()`; `random()`, `token()` and every signature nonce
85
- come from `crypto.getRandomValues`. For file-based confidential jobs use the wasip1 build
153
+ source). `now()` comes from `Date.now()`. Each call asks the host once for 32 bytes of
154
+ entropy (`crypto.getRandomValues`), which seed that run's own ChaCha generator: `random()`,
155
+ `random_bytes(n)`, `token()` and RSA blinding draw from it and never ask the host again, so how
156
+ much randomness a run used does not show. With `labels`, `now()` returns the clock read at the
157
+ start of the run and at each interaction with the host (a hook call), so when and how often the
158
+ program read the clock does not show either. For file-based confidential jobs use the wasip1 build
86
159
  under wasmtime (see the Synsema docs, *WebAssembly*).
87
160
 
88
161
  ## Other languages
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
@@ -11,6 +11,12 @@ export interface HttpRequestPayload {
11
11
  body_base64?: string;
12
12
  /** Segundos. */
13
13
  timeout: number;
14
+ /**
15
+ * v0.6.46: `"error"` cuando la request lleva un secret (en la URL, la query, el body o un header:
16
+ * `fetch` sólo quita `Authorization` al cambiar de host). Pasalo a `fetch` (`{redirect: req.redirect}`): un redirect no puede llevar la clave
17
+ * a otro host. Un hook que lo ignore queda fuera de lo que el motor puede imponer.
18
+ */
19
+ redirect?: "error";
14
20
  }
15
21
 
16
22
  export interface HttpResponsePayload {
@@ -27,6 +33,12 @@ export interface KvStore {
27
33
  delete?(ns: string, key: string): void | Promise<void>;
28
34
  /** Claves del namespace (para `state_all`). */
29
35
  list?(ns: string): string[] | Promise<string[]>;
36
+ /**
37
+ * Comparar-y-cambiar atómico: escribe `value` sólo si el valor actual es exactamente `expected`
38
+ * (`null` = la clave no existe) y devuelve si escribió. Con esto `state_merge`/`state_cas` son
39
+ * atómicos aunque varias instancias compartan el KV; sin esto, esas dos lo dicen con un error.
40
+ */
41
+ cas?(ns: string, key: string, expected: string | null, value: string): boolean | Promise<boolean>;
30
42
  }
31
43
 
32
44
  export interface LlmResult {
@@ -42,9 +54,26 @@ export interface LlmResult {
42
54
  * En la API síncrona los hooks deben ser síncronos; en `*Async` pueden devolver Promise.
43
55
  */
44
56
  export interface Host {
57
+ /**
58
+ * v0.6.46: en nativo, un `require net` con comodín sólo llega a IPs públicas (el motor resuelve
59
+ * y chequea la IP antes de conectar). Acá la conexión es tuya y el motor no ve la IP: si el
60
+ * programa no es tuyo, resolvé el nombre y chequeá la IP a la que vas a conectar (un nombre como
61
+ * `127.0.0.1.nip.io` resuelve al loopback), y conectá a ésa. Negá loopback (`127.0.0.0/8`,
62
+ * `::1`), las redes privadas (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, CGNAT
63
+ * `100.64.0.0/10`, ULA `fc00::/7`), link-local (`169.254.0.0/16` con la metadata de la nube
64
+ * `169.254.169.254`, `fe80::/10`), multicast y los rangos de documentación; una IPv4 dentro de una
65
+ * IPv6 (`::ffff:a.b.c.d`) cuenta como esa IPv4. El hook no sabe si el programa nombró el host
66
+ * (`require net("db")`): si tus programas necesitan llegar a una red privada, nombrá vos esos
67
+ * destinos en el hook.
68
+ */
45
69
  http?(req: HttpRequestPayload): HttpResponsePayload | Promise<HttpResponsePayload>;
46
70
  kv?: KvStore;
47
- llm?(op: string, prompt: string): string | LlmResult | Promise<string | LlmResult>;
71
+ /**
72
+ * `info.principals`: los dueños de lo privado que lleva el prompt (vacío con datos públicos).
73
+ * Sólo llega al hook si el techo lo permite (`llm=<principal>@embedder`). Un hook que lanza hace
74
+ * fallar esa llamada con su mensaje.
75
+ */
76
+ llm?(op: string, prompt: string, info: { principals: string[] }): string | LlmResult | Promise<string | LlmResult>;
48
77
  log?(line: string): void | Promise<void>;
49
78
  /** Una función, o `true` para la pausa por defecto (Atomics.wait / setTimeout). */
50
79
  sleep?: ((secs: number) => void | Promise<void>) | true;
@@ -57,6 +86,53 @@ export interface RunOptions {
57
86
  /** `"sandbox"` (stdout + time) o la lista de `--cap-set`: `"stdout,secret=ETH_*"`. */
58
87
  ceiling?: string;
59
88
  host?: Host;
89
+ /**
90
+ * v0.6.45 — etiquetas de flujo de información (`private`/`declassify`). Con `true`, un valor
91
+ * privado (o una rama que dependió de uno) no llega a ningún sumidero: ni `print`, ni el hook
92
+ * `http` (`fetch`/`http_*`), ni el `kv` (`state_*`, `remember`), ni el `llm`. Es lo que hay que
93
+ * encender para correr código que escribió un modelo sobre datos de un usuario.
94
+ */
95
+ labels?: boolean;
96
+ /** Nombre de una variable global cuyo valor final vuelve en `RunResult.result` CON sus etiquetas. */
97
+ result?: string;
98
+ /**
99
+ * La entrada del host, ligada como variable global antes de correr. Cada clave de `sources`
100
+ * presente llega al programa como `private(valor, principales)`. Exige `labels: true`.
101
+ */
102
+ input?: { var: string; value: unknown; sources?: Record<string, string[]> };
103
+ /**
104
+ * Sólo en el modo asíncrono: si la llamada no termina en este plazo — contado desde que EMPIEZA a
105
+ * correr, no desde que entró a la cola —, su Worker se termina y la Promise falla con un error
106
+ * claro (el código no confiable nunca bloquea el hilo del servidor). Un hook del host sin plazo
107
+ * propio que nunca contesta ocupa su Worker si la llamada no tiene `timeoutMs`. `0` = sin plazo;
108
+ * si no, un número finito de milisegundos, como mucho 2147483647.
109
+ */
110
+ timeoutMs?: number;
111
+ /**
112
+ * Tope de pasos del intérprete (los de `steps()`, deterministas), contados desde que empieza la
113
+ * corrida (en `handle`, cada request). Al pasarlo la corrida termina con `ok: false` y un error
114
+ * que lo dice, no con un trap. Sirve en los dos modos. Un entero positivo.
115
+ */
116
+ maxSteps?: number;
117
+ /**
118
+ * Tope en MB de la memoria que RESERVA la corrida (los bytes vivos que cuenta el allocator del
119
+ * módulo desde que empieza, no lo que la instancia ya tenía). La reserva que lo pasaría se
120
+ * rechaza y la corrida termina con `ok: false` y "memory limit reached" (en `handle`, un 500).
121
+ * Cuenta bytes pedidos: la memoria del módulo puede crecer hasta ~2× eso y no se devuelve.
122
+ * Igual que `maxSteps`. Un entero positivo.
123
+ */
124
+ maxMemoryMb?: number;
125
+ /**
126
+ * v0.6.46 — módulos de confianza: `{ nombre: fuente }`. El programa los importa con
127
+ * `use "trusted:nombre" as t` y sólo puede llamar a sus tasks exportadas, que corren con SUS
128
+ * `require` (acotados por `trustedCeiling`) y nada de lo del programa. Es la forma de dejar que
129
+ * código escrito por un modelo, bajo un `ceiling` sin `declassify`, publique sólo lo que la
130
+ * política de una persona publica. Los argumentos de una task de confianza son datos (una task
131
+ * o una lambda entre ellos es error).
132
+ */
133
+ trusted?: Record<string, string>;
134
+ /** El techo de los módulos de confianza, con la gramática de `ceiling` (`"stdout,declassify=clientes"`). */
135
+ trustedCeiling?: string;
60
136
  }
61
137
 
62
138
  export interface AuditEntry {
@@ -77,6 +153,14 @@ export interface RunResult {
77
153
  llm_tokens: number;
78
154
  /** v0.6.42 — el código de salida: el de `exit(code)` si el programa lo pidió, si no 0 (ok) o 1. */
79
155
  exit: number;
156
+ /** Los pasos del intérprete que llevó la corrida; `null` con `labels` si tocó un valor o una rama privada. */
157
+ steps: number | null;
158
+ /** Con `result`: el valor final de esa variable (las partes privadas como `{"$private": [...]}`). */
159
+ result?: unknown;
160
+ /** Con `labels`: cada `declassify` que corrió (quién, qué principal, dónde). */
161
+ declassify?: unknown[];
162
+ /** Con `labels`: si algún valor privado pasó por la corrida (entonces `steps` no se publica). */
163
+ private_seen?: boolean;
80
164
  }
81
165
 
82
166
  export interface TestResult {
@@ -125,20 +209,50 @@ export declare function instantiate(
125
209
  hostCall: (kind: string, payload: any) => any,
126
210
  ): Promise<{ exports: WebAssembly.Exports; call(request: object): any }>;
127
211
 
212
+ export interface LoadOptions {
213
+ /** Cuántos Workers usa el modo asíncrono (default 1). Con más, `runAsync` corre en paralelo. */
214
+ workers?: number;
215
+ /**
216
+ * Cuánto puede tardar un Worker en arrancar antes de darlo por perdido (ms, default 30 000; entre 1
217
+ * y 2147483647). Opción de `Synsema.load`/`loadNode`.
218
+ */
219
+ startMs?: number;
220
+ }
221
+
222
+ export declare function instantiateSync(
223
+ module: WebAssembly.Module,
224
+ hostCall: (kind: string, payload: any) => any,
225
+ ): { exports: WebAssembly.Exports; call(request: object): any };
226
+
128
227
  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). */
228
+ constructor(module: WebAssembly.Module, opts?: LoadOptions);
229
+ static load(src: ModuleSource, opts?: LoadOptions): Promise<Synsema>;
230
+ /**
231
+ * Node: carga el `synsema.wasm` que trae el paquete (sin resolver rutas; anda en Next, rutas
232
+ * `/api` y Server Actions).
233
+ */
234
+ static loadNode(opts?: LoadOptions): Promise<Synsema>;
235
+ /**
236
+ * Prepara la instancia síncrona (obligatorio antes de run/test/check/handle). Después de un trap
237
+ * la instancia se recrea sola; no hace falta volver a llamarlo.
238
+ */
132
239
  ready(): Promise<this>;
133
240
  version(): string;
134
241
  run(source: string, opts?: RunOptions): RunResult;
135
242
  test(source: string, opts?: RunOptions): TestResult;
136
243
  check(source: string, opts?: { filename?: string }): CheckResult;
137
244
  handle(source: string, request: HandleRequest, opts?: RunOptions): HandleResult;
138
- /** Modo asíncrono (Worker + Atomics): los hooks del host pueden ser async. */
245
+ /**
246
+ * Modo asíncrono (Worker + Atomics): los hooks del host pueden ser async. Las llamadas esperan en
247
+ * una sola cola y van al primer Worker libre (hasta `workers`).
248
+ */
139
249
  runAsync(source: string, opts?: RunOptions): Promise<RunResult>;
140
250
  testAsync(source: string, opts?: RunOptions): Promise<TestResult>;
141
251
  handleAsync(source: string, request: HandleRequest, opts?: RunOptions): Promise<HandleResult>;
252
+ /**
253
+ * Termina los Workers del modo asíncrono. Es final: lo que espera en la cola falla con "closed" y
254
+ * una llamada asíncrona posterior también (la API síncrona sigue andando).
255
+ */
142
256
  close(): Promise<void>;
143
257
  }
144
258
 
package/index.mjs CHANGED
@@ -14,6 +14,11 @@
14
14
  // En el navegador requiere cross-origin isolation (COOP/COEP) para SharedArrayBuffer.
15
15
  //
16
16
  // No hay estado global: cada `load()` es una instancia (memoria lineal propia).
17
+ //
18
+ // v0.6.46 (E7): la conexión del `http` del host es del embebedor, y el motor no ve a qué IP va. En
19
+ // nativo un `net` con comodín sólo llega a IPs públicas; acá eso lo tiene que hacer tu hook:
20
+ // resolver el nombre, chequear la IP a la que conecta (los rangos están en `Host.http`, en
21
+ // index.d.ts) y conectar a ésa. El hook no sabe si el programa nombró el host.
17
22
 
18
23
  const te = new TextEncoder();
19
24
  const td = new TextDecoder();
@@ -54,16 +59,44 @@ export function offersOf(host) {
54
59
  const o = {};
55
60
  if (typeof host.http === "function") o.http = true;
56
61
  if (host.kv && typeof host.kv.get === "function") o.kv = true;
62
+ // v0.6.45: comparar-y-cambiar, lo que hace atómicos state_merge/state_cas con un KV compartido.
63
+ if (host.kv && typeof host.kv.cas === "function") o.kv_cas = true;
57
64
  if (typeof host.llm === "function") o.llm = true;
58
65
  if (typeof host.log === "function") o.log = true;
59
66
  if (typeof host.sleep === "function" || host.sleep === true) o.sleep = true;
60
67
  return o;
61
68
  }
62
69
 
70
+ /**
71
+ * v0.6.45 (ronda 4, M6) — el texto de un error que NUNCA lanza: un hook puede lanzar
72
+ * `Object.create(null)` o un objeto cuyo `message` es un getter que lanza, y un `String(e)` lanzaba
73
+ * fuera del manejo (el proceso caía o el Worker quedaba esperando para siempre).
74
+ */
75
+ export function safeMsg(e) {
76
+ try {
77
+ if (e && typeof e.message === "string") return e.message;
78
+ return String(e);
79
+ } catch {
80
+ return "the host hook threw a value that cannot be shown";
81
+ }
82
+ }
83
+
84
+ /** `JSON.stringify` de la respuesta de un hook, o un `{error}` si no es JSON (un BigInt, un ciclo). */
85
+ export function safeJson(result, kind) {
86
+ try {
87
+ return JSON.stringify(result ?? null);
88
+ } catch (e) {
89
+ return JSON.stringify({ error: `host ${kind} returned a non-JSON value: ${safeMsg(e)}` });
90
+ }
91
+ }
92
+
63
93
  /** Un hook async usado desde la API síncrona devolvería una Promise donde el intérprete
64
94
  * espera un valor: en vez de un `{status: 0}` mudo, un error que nombra el fix. */
65
95
  function syncOnly(kind, r) {
66
96
  if (r && typeof r.then === "function") {
97
+ // Ronda 3 (M9): la Promise queda con un handler; si no, al rechazar tiraba el proceso
98
+ // (unhandledRejection) después de que la corrida ya había devuelto.
99
+ r.then(undefined, () => {});
67
100
  throw new Error(`synsema: the host \`${kind}\` hook returned a Promise — use runAsync/handleAsync (Worker + Atomics) or make the hook synchronous`);
68
101
  }
69
102
  return r;
@@ -89,15 +122,19 @@ export function dispatchHostSync(host, kind, payload) {
89
122
  return {};
90
123
  case "kv_list":
91
124
  return host.kv ? { keys: host.kv.list ? syncOnly("kv.list", host.kv.list(payload.ns)) : [] } : undefined;
125
+ case "kv_cas":
126
+ return host.kv && typeof host.kv.cas === "function"
127
+ ? casReply(syncOnly("kv.cas", host.kv.cas(payload.ns, payload.key, payload.expected ?? null, payload.value)))
128
+ : undefined;
92
129
  case "llm":
93
- return typeof host.llm === "function" ? normalizeLlm(syncOnly("llm", host.llm(payload.op, payload.prompt))) : undefined;
130
+ return typeof host.llm === "function" ? normalizeLlm(syncOnly("llm", host.llm(payload.op, payload.prompt, { principals: payload.principals ?? [] }))) : undefined;
94
131
  case "log":
95
132
  if (typeof host.log !== "function") return undefined;
96
- host.log(payload.line);
133
+ syncOnly("log", host.log(payload.line));
97
134
  return {};
98
135
  case "sleep":
99
136
  if (typeof host.sleep === "function") {
100
- host.sleep(payload.secs);
137
+ syncOnly("sleep", host.sleep(payload.secs));
101
138
  return {};
102
139
  }
103
140
  if (host.sleep === true) {
@@ -128,8 +165,12 @@ export async function dispatchHostAsync(host, kind, payload) {
128
165
  return {};
129
166
  case "kv_list":
130
167
  return host.kv ? { keys: host.kv.list ? await host.kv.list(payload.ns) : [] } : undefined;
168
+ case "kv_cas":
169
+ return host.kv && typeof host.kv.cas === "function"
170
+ ? casReply(await host.kv.cas(payload.ns, payload.key, payload.expected ?? null, payload.value))
171
+ : undefined;
131
172
  case "llm":
132
- return typeof host.llm === "function" ? normalizeLlm(await host.llm(payload.op, payload.prompt)) : undefined;
173
+ return typeof host.llm === "function" ? normalizeLlm(await host.llm(payload.op, payload.prompt, { principals: payload.principals ?? [] })) : undefined;
133
174
  case "log":
134
175
  if (typeof host.log !== "function") return undefined;
135
176
  await host.log(payload.line);
@@ -194,6 +235,23 @@ export function sleepSync(secs) {
194
235
  * (el bridge async lo implementa con Atomics). Devuelve `{ call(request) -> response }`.
195
236
  */
196
237
  export async function instantiate(module, hostCall) {
238
+ const { imports, bind } = hostImports(hostCall);
239
+ const instance = await WebAssembly.instantiate(module, imports);
240
+ return bind(instance.exports);
241
+ }
242
+
243
+ /**
244
+ * v0.6.45 (D4) — lo mismo, SÍNCRONO (`new WebAssembly.Instance`): con el `Module` ya compilado es
245
+ * barato, y es lo que deja a la API síncrona recrear la instancia después de un trap sin pedirle
246
+ * al usuario un `await syn.ready()`. (En el main thread de un navegador puede estar acotado por
247
+ * tamaño; ahí se cae al camino de siempre.)
248
+ */
249
+ export function instantiateSync(module, hostCall) {
250
+ const { imports, bind } = hostImports(hostCall);
251
+ return bind(new WebAssembly.Instance(module, imports).exports);
252
+ }
253
+
254
+ function hostImports(hostCall) {
197
255
  let exports = null;
198
256
  const mem = () => new Uint8Array(exports.memory.buffer);
199
257
  const writeBytes = (bytes) => {
@@ -210,10 +268,12 @@ export async function instantiate(module, hostCall) {
210
268
  try {
211
269
  result = hostCall(kind, payload);
212
270
  } catch (e) {
213
- result = { error: String(e && e.message ? e.message : e) };
271
+ result = { error: safeMsg(e) };
214
272
  }
215
273
  if (result === undefined) return 0;
216
- const bytes = te.encode(JSON.stringify(result ?? null));
274
+ // Ronda 4 (B1): un valor que no es JSON vuelve como error del hook (antes hacía trapear el
275
+ // módulo y se perdía la corrida).
276
+ const bytes = te.encode(safeJson(result, kind));
217
277
  const ptr = exports.synsema_alloc(4 + bytes.length);
218
278
  const m = mem();
219
279
  new DataView(m.buffer).setUint32(ptr, bytes.length, true);
@@ -233,8 +293,14 @@ export async function instantiate(module, hostCall) {
233
293
  },
234
294
  },
235
295
  };
236
- const instance = await WebAssembly.instantiate(module, imports);
237
- exports = instance.exports;
296
+ const bind = (ex) => {
297
+ exports = ex;
298
+ return callable(ex, mem, writeBytes);
299
+ };
300
+ return { imports, bind };
301
+ }
302
+
303
+ function callable(exports, mem, writeBytes) {
238
304
  return {
239
305
  exports,
240
306
  call(request) {
@@ -243,8 +309,20 @@ export async function instantiate(module, hostCall) {
243
309
  let out;
244
310
  try {
245
311
  out = exports.synsema_call(rp, req.length);
312
+ } catch (e) {
313
+ // v0.6.45 (D2): el allocator del módulo rechazó una reserva por `maxMemoryMb` y Rust hizo
314
+ // trap. La instancia igual se descarta (lo hace quien llama), pero la corrida no "trapeó":
315
+ // se pasó del tope que el host pidió, y eso vuelve como un error de la corrida.
316
+ if (typeof exports.synsema_limit_hit === "function" && exports.synsema_limit_hit() === 1) {
317
+ e.synsemaResult = limitResult(request);
318
+ }
319
+ throw e;
246
320
  } finally {
247
- exports.synsema_free(rp, req.length);
321
+ try {
322
+ exports.synsema_free(rp, req.length);
323
+ } catch {
324
+ // instancia rota por el trap: se descarta entera
325
+ }
248
326
  }
249
327
  const m = mem();
250
328
  const len = new DataView(m.buffer).getUint32(out, true);
@@ -255,11 +333,59 @@ export async function instantiate(module, hostCall) {
255
333
  };
256
334
  }
257
335
 
336
+ const MEMORY_LIMIT = "Runtime error: memory limit reached: the run reserved more than maxMemoryMb";
337
+
338
+ /** El resultado de una corrida cortada por `maxMemoryMb`, con la forma de su `op`. */
339
+ function limitResult(request) {
340
+ if (request.op === "handle") {
341
+ return {
342
+ status: 500,
343
+ content_type: "application/json",
344
+ headers: [["content-type", "application/json"]],
345
+ body: JSON.stringify({ error: "memory limit reached", status: 500 }),
346
+ log: [],
347
+ errors: [MEMORY_LIMIT],
348
+ audit: [],
349
+ };
350
+ }
351
+ if (request.op === "test") return { ok: false, passed: 0, failed: 0, lines: [MEMORY_LIMIT], audit: [] };
352
+ const r = { ok: false, output: [], errors: [MEMORY_LIMIT], audit: [], llm_tokens: 0, exit: 1 };
353
+ // v0.6.46 (E6): con etiquetas, que la corrida se haya cortado por memoria puede depender de un
354
+ // valor privado (un bit por corrida, como cualquier error suyo): el host lo trata como sensible,
355
+ // igual que una corrida que terminó con `private_seen`. Sin saber si tocó algo privado, sí.
356
+ if (request.labels) {
357
+ r.steps = null;
358
+ r.private_seen = true;
359
+ }
360
+ return r;
361
+ }
362
+
363
+ /**
364
+ * v0.6.45 (ronda 2, B2): `kv.cas` del host tiene que contestar `true` o `false`. Un `cas` sin
365
+ * `return` (undefined) contaba como `false` y el motor reintentaba 64 veces hasta "kept changing".
366
+ */
367
+ function casReply(v) {
368
+ return typeof v === "boolean" ? { swapped: v } : { error: `kv.cas must return true or false, got ${v === undefined ? "undefined" : JSON.stringify(v)}` };
369
+ }
370
+
258
371
  function requestOf(op, source, opts = {}) {
259
372
  const r = { op, source, filename: opts.filename ?? "<embedded>" };
260
373
  if (opts.env) r.env = opts.env;
261
374
  if (opts.ceiling) r.ceiling = opts.ceiling;
262
375
  if (opts.host) r.host = offersOf(opts.host);
376
+ // v0.6.45 (D1): etiquetas de flujo. Antes no se reenviaban y el motor las tenía apagadas aunque
377
+ // el host las pidiera.
378
+ if (opts.labels) r.labels = true;
379
+ if (opts.result) r.result = opts.result;
380
+ if (opts.input) r.input = { var: opts.input.var, value: opts.input.value, sources: opts.input.sources ?? {} };
381
+ // v0.6.45 (D2): límites de la corrida (los mide el motor: pasos deterministas y la memoria del módulo).
382
+ // `!= null`: un 0 (o un valor inválido) llega al motor, que lo rechaza con un error claro; antes
383
+ // se descartaba por falsy y la corrida quedaba sin límite.
384
+ if (opts.maxSteps != null) r.max_steps = opts.maxSteps;
385
+ if (opts.maxMemoryMb != null) r.max_memory_mb = opts.maxMemoryMb;
386
+ // v0.6.46 (E3b): módulos de confianza del embebedor y su techo.
387
+ if (opts.trusted) r.trusted = opts.trusted;
388
+ if (opts.trustedCeiling) r.trusted_ceiling = opts.trustedCeiling;
263
389
  return r;
264
390
  }
265
391
 
@@ -277,15 +403,54 @@ function normalizeRequest(request) {
277
403
  }
278
404
 
279
405
  export class Synsema {
280
- /** @param {WebAssembly.Module} module */
281
- constructor(module) {
406
+ /**
407
+ * @param {WebAssembly.Module} module
408
+ * @param {{workers?: number}} [opts] `workers`: cuántos Workers usa el modo asíncrono (default 1).
409
+ */
410
+ constructor(module, opts = {}) {
282
411
  this.module = module;
283
412
  this._inst = null;
413
+ this._workers = Math.max(1, opts.workers | 0 || 1);
414
+ // Ronda 4 (B1): cuánto puede tardar un Worker en arrancar (default 30 s). Validado como
415
+ // `timeoutMs`: un valor fuera del rango de los timers vencía en 1 ms y ningún Worker arrancaba.
416
+ if (opts.startMs != null && !(Number.isFinite(opts.startMs) && opts.startMs > 0 && opts.startMs <= 2147483647)) {
417
+ throw new Error("synsema: startMs must be a number of milliseconds between 1 and 2147483647");
418
+ }
419
+ this._startMs = opts.startMs;
420
+ this._bridges = [];
284
421
  }
285
422
 
286
423
  /** Carga el .wasm (bytes, Module, URL/path, Response). */
287
- static async load(src) {
288
- return new Synsema(await readModule(src));
424
+ static async load(src, opts = {}) {
425
+ return new Synsema(await readModule(src), opts);
426
+ }
427
+
428
+ /**
429
+ * v0.6.45 (D5) — Node: carga el `synsema.wasm` que viene en el paquete, sin que el que lo usa
430
+ * resuelva la ruta. Lo lee del directorio de este archivo y, si un bundler lo movió (Turbopack,
431
+ * webpack), de `node_modules/@synsema/wasm`: anda en un script, en una ruta `/api` y en una
432
+ * Server Action de Next.
433
+ */
434
+ static async loadNode(opts = {}) {
435
+ const { readFile } = await import("node:fs/promises");
436
+ const { fileURLToPath } = await import("node:url");
437
+ const { dirname, join } = await import("node:path");
438
+ const candidates = [];
439
+ try {
440
+ candidates.push(join(dirname(fileURLToPath(import.meta.url)), "synsema.wasm"));
441
+ } catch {
442
+ // import.meta.url reescrito a algo que no es file:
443
+ }
444
+ candidates.push(join(process.cwd(), "node_modules", "@synsema", "wasm", "synsema.wasm"));
445
+ let lastErr;
446
+ for (const p of candidates) {
447
+ try {
448
+ return new Synsema(await WebAssembly.compile(await readFile(p)), opts);
449
+ } catch (e) {
450
+ lastErr = e;
451
+ }
452
+ }
453
+ throw new Error(`synsema: cannot find synsema.wasm (tried ${candidates.join(", ")}): ${lastErr && lastErr.message}`);
289
454
  }
290
455
 
291
456
  async _instance() {
@@ -304,10 +469,20 @@ export class Synsema {
304
469
  try {
305
470
  return inst.call(request);
306
471
  } catch (e) {
307
- // Un trap deja la instancia inservible: se reinstancia en la próxima llamada.
472
+ // Un trap deja la instancia inservible: se recrea ya (sincrónico, con el Module compilado),
473
+ // así la próxima llamada anda sin `await syn.ready()` (v0.6.45, D4). Si el host no deja
474
+ // instanciar sincrónico, queda descartada y `ready()` la recrea.
308
475
  this._inst = null;
309
476
  this._ready = null;
310
- throw new Error(`synsema: the module trapped (${e && e.message ? e.message : e}); the instance was discarded`);
477
+ try {
478
+ const self = this;
479
+ this._inst = instantiateSync(this.module, (kind, payload) => dispatchHostSync(self._host, kind, payload));
480
+ this._ready = Promise.resolve(this._inst);
481
+ } catch {
482
+ // sin instancia: la próxima llamada pide `ready()`
483
+ }
484
+ if (e && e.synsemaResult) return e.synsemaResult;
485
+ throw new Error(`synsema: the module trapped (${safeMsg(e)}); the instance was recreated`);
311
486
  } finally {
312
487
  this._host = null;
313
488
  }
@@ -343,28 +518,129 @@ export class Synsema {
343
518
 
344
519
  // ---- modo asíncrono (Worker + Atomics) ----
345
520
 
346
- async _bridge() {
347
- if (!this._worker) {
348
- const { createBridge } = await import("./bridge.mjs");
349
- this._worker = await createBridge(this.module);
521
+ /**
522
+ * v0.6.45 (ronda 2, M8) — una cola CENTRAL: cada llamada asíncrona entra acá y `_pump()` (sin
523
+ * `await`, así no hay carreras) la entrega a un Worker libre, o crea uno mientras no se llegó a
524
+ * `workers`. Un Worker corre una llamada a la vez. Antes cada llamada elegía Worker por su cuenta
525
+ * después de un `await`: una ráfaga de 4 con `workers: 2` quedaba toda en uno.
526
+ */
527
+ _pump() {
528
+ this._queue = this._queue ?? [];
529
+ this._bridges = (this._bridges ?? []).filter((b) => !b.closed());
530
+ this._spawning = this._spawning ?? 0;
531
+ while (this._queue.length > 0 && !this._closed) {
532
+ const idle = this._bridges.find((b) => !b.busy());
533
+ if (idle) {
534
+ this._dispatch(idle, this._queue.shift());
535
+ continue;
536
+ }
537
+ if (this._bridges.length + this._spawning < this._workers) {
538
+ // Ronda 4 (B1): después de un arranque fallido con Workers sanos, no se reintenta antes de un
539
+ // segundo (cada intento instancia el .wasm entero, y el motivo suele ser falta de memoria).
540
+ // El timer se arma ACÁ, por lo que falta (revisión H1: armado sólo al fallar, un segundo fallo
541
+ // o un timer que vencía unos ms antes dejaban la cola sin nadie que la despertara). Sin
542
+ // Workers sanos no se espera: o arranca uno, o la cola falla.
543
+ const wait = (this._spawnRetryAt ?? 0) - performance.now();
544
+ if (wait > 0 && this._bridges.length > 0) {
545
+ if (!this._retryTimer) {
546
+ this._retryTimer = setTimeout(() => {
547
+ this._retryTimer = null;
548
+ this._pump();
549
+ }, Math.ceil(wait) + 1);
550
+ if (typeof this._retryTimer.unref === "function") this._retryTimer.unref();
551
+ }
552
+ break;
553
+ }
554
+ this._spawning++;
555
+ this._spawnAbort = this._spawnAbort ?? new AbortController();
556
+ const signal = this._spawnAbort.signal;
557
+ import("./bridge.mjs")
558
+ .then(({ createBridge }) => createBridge(this.module, { signal, startMs: this._startMs }))
559
+ .then(
560
+ (b) => {
561
+ this._spawning--;
562
+ // `close()` llegó mientras nacía: se cierra, no queda vivo.
563
+ if (this._closed) return b.close();
564
+ this._bridges.push(b);
565
+ this._pump();
566
+ },
567
+ (err) => {
568
+ this._spawning--;
569
+ // No se pudo crear un Worker (ronda 4, revisión: sin esto, con `workers` ≥ 2 el arranque
570
+ // fallido se reintentaba sin pausa y la cola no fallaba nunca). Tres casos:
571
+ // - hay Workers sanos: lo encolado sigue con ellos y el próximo arranque espera un
572
+ // segundo (un solo timer, que no retiene el proceso);
573
+ // - no hay sanos pero otro arranque está en vuelo: se espera a ése;
574
+ // - no queda ninguno: lo encolado falla con esta causa, sin colgarse.
575
+ if (this._bridges.filter((b) => !b.closed()).length > 0) {
576
+ this._spawnRetryAt = performance.now() + 1000;
577
+ return this._pump(); // arma el timer de la espera
578
+ }
579
+ if (this._spawning > 0) return;
580
+ const q = this._queue.splice(0);
581
+ for (const it of q) it.reject(err);
582
+ },
583
+ );
584
+ }
585
+ break;
586
+ }
587
+ }
588
+
589
+ _dispatch(b, it) {
590
+ b.call(it.request, it.host, it.timeoutMs).then(it.resolve, (e) => {
591
+ // No se llegó a mandar (el Worker se cerró antes): vuelve al principio de la cola. Lo que sí
592
+ // corrió (un timeout, un Worker que murió en el medio) NO se reintenta: pudo tener efectos.
593
+ if (e && e.synsemaWorkerClosed && !this._closed) {
594
+ this._queue.unshift(it);
595
+ } else {
596
+ it.reject(e);
597
+ }
598
+ }).finally(() => this._pump());
599
+ }
600
+
601
+ /**
602
+ * v0.6.45 (D2) — `timeoutMs`: si la llamada no terminó en ese plazo (contado desde que EMPIEZA
603
+ * a correr, no desde que entró a la cola), su Worker se termina (el código corría en otro hilo:
604
+ * el del servidor nunca se bloquea) y la llamada falla con un error claro. Los hooks del host que
605
+ * ya estaban en curso (un `fetch`) no se cortan: terminan solos y su resultado se descarta.
606
+ */
607
+ _callAsync(request, opts) {
608
+ if (this._closed) return Promise.reject(new Error("synsema: closed (close() is final; load a new instance)"));
609
+ // Ronda 3 (B1): un plazo fuera del rango de los timers (o `Infinity`) vencía en 1 ms. `0` sigue
610
+ // siendo "sin plazo", como antes (ronda 4).
611
+ if (opts.timeoutMs != null && !(Number.isFinite(opts.timeoutMs) && opts.timeoutMs >= 0 && opts.timeoutMs <= 2147483647)) {
612
+ return Promise.reject(new Error("synsema: timeoutMs must be 0 (no deadline) or a number of milliseconds up to 2147483647"));
350
613
  }
351
- return this._worker;
614
+ return new Promise((resolve, reject) => {
615
+ this._queue = this._queue ?? [];
616
+ this._queue.push({ request, host: opts.host, timeoutMs: opts.timeoutMs > 0 ? opts.timeoutMs : 0, resolve, reject });
617
+ this._pump();
618
+ });
352
619
  }
353
620
  async runAsync(source, opts = {}) {
354
- return (await this._bridge()).call(requestOf("run", source, opts), opts.host);
621
+ return this._callAsync(requestOf("run", source, opts), opts);
355
622
  }
356
623
  async testAsync(source, opts = {}) {
357
- return (await this._bridge()).call(requestOf("test", source, opts), opts.host);
624
+ return this._callAsync(requestOf("test", source, opts), opts);
358
625
  }
359
626
  async handleAsync(source, request, opts = {}) {
360
- return (await this._bridge()).call({ ...requestOf("handle", source, opts), request: normalizeRequest(request) }, opts.host);
627
+ return this._callAsync({ ...requestOf("handle", source, opts), request: normalizeRequest(request) }, opts);
361
628
  }
362
- /** Termina el Worker del modo asíncrono (si se creó). */
629
+ /**
630
+ * Termina los Workers del modo asíncrono. Es FINAL (ronda 2, B1): lo que esperaba en la cola falla
631
+ * con "closed", un Worker que estaba naciendo se cierra al nacer, y una llamada asíncrona posterior
632
+ * falla. La API síncrona sigue andando.
633
+ */
363
634
  async close() {
364
- if (this._worker) {
365
- await this._worker.close();
366
- this._worker = null;
367
- }
635
+ this._closed = true;
636
+ if (this._retryTimer) clearTimeout(this._retryTimer);
637
+ // Los Workers que están naciendo se abortan (ronda 3, M9).
638
+ this._spawnAbort?.abort();
639
+ const q = (this._queue ?? []).splice(0);
640
+ for (const it of q) it.reject(new Error("synsema: closed (close() is final; load a new instance)"));
641
+ const bs = this._bridges ?? [];
642
+ this._bridges = [];
643
+ await Promise.all(bs.map((b) => b.close()));
368
644
  }
369
645
  }
370
646
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synsema/wasm",
3
- "version": "0.6.44",
3
+ "version": "0.6.46",
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
  });