@synsema/wasm 0.6.43 → 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 +40 -1
- package/bridge.mjs +134 -21
- package/index.d.ts +82 -4
- package/index.mjs +286 -26
- package/package.json +1 -1
- package/synsema.wasm +0 -0
- package/worker.mjs +19 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
266
|
+
result = { error: safeMsg(e) };
|
|
214
267
|
}
|
|
215
268
|
if (result === undefined) return 0;
|
|
216
|
-
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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
|
|
605
|
+
return this._callAsync(requestOf("run", source, opts), opts);
|
|
355
606
|
}
|
|
356
607
|
async testAsync(source, opts = {}) {
|
|
357
|
-
return
|
|
608
|
+
return this._callAsync(requestOf("test", source, opts), opts);
|
|
358
609
|
}
|
|
359
610
|
async handleAsync(source, request, opts = {}) {
|
|
360
|
-
return
|
|
611
|
+
return this._callAsync({ ...requestOf("handle", source, opts), request: normalizeRequest(request) }, opts);
|
|
361
612
|
}
|
|
362
|
-
/**
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
});
|