@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 +77 -4
- package/bridge.mjs +134 -21
- package/index.d.ts +119 -5
- package/index.mjs +304 -28
- 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,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()
|
|
85
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
271
|
+
result = { error: safeMsg(e) };
|
|
214
272
|
}
|
|
215
273
|
if (result === undefined) return 0;
|
|
216
|
-
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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
|
|
621
|
+
return this._callAsync(requestOf("run", source, opts), opts);
|
|
355
622
|
}
|
|
356
623
|
async testAsync(source, opts = {}) {
|
|
357
|
-
return
|
|
624
|
+
return this._callAsync(requestOf("test", source, opts), opts);
|
|
358
625
|
}
|
|
359
626
|
async handleAsync(source, request, opts = {}) {
|
|
360
|
-
return
|
|
627
|
+
return this._callAsync({ ...requestOf("handle", source, opts), request: normalizeRequest(request) }, opts);
|
|
361
628
|
}
|
|
362
|
-
/**
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
});
|