tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

runtime.mjs (10977B)


      1 // Experimental browser/Node adapter. Scope temporary handles; dispose after use.
      2 // Handles returned to callers remain owned until release() or dispose().
      3 export class WasmRuntime {
      4   static async create(bytes, {unbounded = false} = {}) {
      5     const module = await WebAssembly.compile(bytes);
      6     if (WebAssembly.Module.imports(module).length) throw new Error('Unexpected WASM imports');
      7     const runtime = new WasmRuntime(await WebAssembly.instantiate(module));
      8     runtime.unbounded = unbounded;
      9     return runtime;
     10   }
     11   constructor(instance) {
     12     this.e = instance.exports;
     13     if (this.e.arb_abi_version() !== 3) throw new Error('ABI v3 required');
     14     this.ctx = this.e.arboricx_init();
     15     if (!this.ctx) throw new Error('WASM initialization failed');
     16     this.scratch = this.e.arb_host_alloc(16);
     17     if (!this.scratch) { this.e.arboricx_free(this.ctx); throw new Error('Scratch allocation failed'); }
     18     this.scopes = [];
     19     this.owners = new Map();
     20     try { this.leaf = this.handle('arb_leaf'); }
     21     catch (error) { this.dispose(); throw error; }
     22   }
     23   check(status) {
     24     if ([1,2,7].includes(status)) this.poisoned = true;
     25     if (status !== 0) throw new Error(`Arboricx status ${status}`);
     26   }
     27   call(name, ...args) {
     28     if (!this.ctx) throw new Error('Disposed WASM runtime');
     29     try { this.check(this.e[name](this.ctx, ...args)); }
     30     catch (error) { if (error instanceof WebAssembly.RuntimeError) this.poisoned = true; throw error; }
     31   }
     32   // Recreate views after every call: memory.grow detaches previous buffers.
     33   view() { return new DataView(this.e.memory.buffer); }
     34   handle(name, ...args) {
     35     this.call(name, ...args, this.scratch);
     36     return this.own(this.view().getBigUint64(this.scratch, true));
     37   }
     38   own(handle) {
     39     const scope = this.scopes.at(-1);
     40     this.owners.set(handle, scope);
     41     scope?.add(handle);
     42     return handle;
     43   }
     44   // Synchronous scopes only. keep() transfers a newly owned handle to the parent;
     45   // borrowed inputs are never implicitly released. Failure also unwinds roots.
     46   scope(fn) {
     47     if (fn.constructor.name === 'AsyncFunction') throw new Error('Scopes must be synchronous');
     48     const handles = new Set(), escaped = new Set(), parent = this.scopes.at(-1);
     49     this.scopes.push(handles);
     50     const keep = h => {
     51       if (!handles.has(h)) throw new Error('keep requires a handle owned by this scope');
     52       escaped.add(h); return h;
     53     };
     54     try {
     55       const result = fn({keep});
     56       if (result && typeof result.then === 'function') throw new Error('Scopes must be synchronous');
     57       for (const h of escaped) {
     58         if (!handles.has(h)) throw new Error('Escaped handle was released');
     59       }
     60       for (const h of escaped) { handles.delete(h); parent?.add(h); this.owners.set(h,parent); }
     61       return result;
     62     } finally {
     63       this.scopes.pop();
     64       for (const h of handles) this.release(h);
     65     }
     66   }
     67   // Exclusive outer operation only. Session callers serialize operations; nested
     68   // synchronous scopes work normally. No other async operation may enter.
     69   async scopeAsync(fn) {
     70     if (this.scopes.length) throw new Error('Async scope requires exclusive ownership');
     71     const handles = new Set(); this.scopes.push(handles);
     72     try { return await fn(); }
     73     finally { this.scopes.pop(); for (const h of handles) this.release(h); }
     74   }
     75   // Interpreter turn boundary inside an exclusive scopeAsync. Validate every
     76   // live root first, then retire only handles owned by this scope. Borrowed
     77   // caller roots remain caller-owned; no stale root can partially sweep state.
     78   sweepScope(roots) {
     79     const scope=this.scopes.at(-1);if(!scope)throw Error('Root sweep requires an owned scope');
     80     const live=new Set(roots);
     81     for(const h of live)if(!this.owners.has(h))throw Error('Stale interpreter root');
     82     for(const h of scope)if(!live.has(h))this.release(h);
     83     this.collect();
     84   }
     85   retain(x) { return this.handle('arb_retain', x); }
     86   collect() { this.call('arb_collect'); }
     87   stats() {
     88     this.call('arb_host_stats', this.scratch, this.scratch + 4, this.scratch + 8, this.scratch + 12);
     89     const v = this.view();
     90     return {slots:v.getUint32(this.scratch,true), owned:v.getUint32(this.scratch+4,true), nodes:v.getUint32(this.scratch+8,true), liveAfterCollect:v.getUint32(this.scratch+12,true)};
     91   }
     92   buffer(bytes, fn) {
     93     if (!this.ctx) throw new Error('Disposed WASM runtime');
     94     const p = this.e.arb_host_alloc(Math.max(1, bytes.length));
     95     if (!p) { this.poisoned = true; throw new Error('WASM buffer allocation failed'); }
     96     try { new Uint8Array(this.e.memory.buffer, p, bytes.length).set(bytes); return fn(p); }
     97     finally { this.e.arb_host_free(p, Math.max(1, bytes.length)); }
     98   }
     99   load(bytes, name) {
    100     if (name !== undefined && (typeof name !== 'string' || name.includes('\0'))) throw new Error('Invalid export name');
    101     return this.buffer(bytes, p => name === undefined
    102       ? this.handle('arb_load_bundle_default', p, bytes.length)
    103       : this.buffer(new TextEncoder().encode(name + '\0'), n => this.handle('arb_load_bundle', p, bytes.length, n)));
    104   }
    105   ofBytes(bytes) { return this.buffer(bytes, p => this.handle('arb_of_bytes', p, bytes.length)); }
    106   textLimit(limit) {
    107     if (!Number.isSafeInteger(limit) || limit < 0 || limit > 0xffffffff) throw new Error('Invalid scalar limit');
    108   }
    109   ofText(value, limit = 4096) {
    110     this.textLimit(limit);
    111     if (typeof value !== 'string' || value.length > limit * 2) throw new Error('Text limit/type');
    112     let count = 0;
    113     for (const c of value) {
    114       const scalar = c.codePointAt(0);
    115       if (scalar >= 0xd800 && scalar <= 0xdfff) throw new Error('Unpaired Unicode surrogate');
    116       if (++count > limit) throw new Error('Text limit');
    117     }
    118     const bytes = new TextEncoder().encode(value);
    119     return this.buffer(bytes, p => this.handle('arb_host_of_text', p, bytes.length, limit));
    120   }
    121   toText(x, limit = 4096) {
    122     this.textLimit(limit);
    123     // Preserve an initial U+FEFF; this is text, not a source-file BOM policy.
    124     return new TextDecoder('utf-8', {fatal:true, ignoreBOM:true}).decode(this.output('arb_host_to_text', x, limit));
    125   }
    126   // Labels are snapshot-local numbers, not owned handles or live node IDs.
    127   preview(x, limit = 64, fuel = undefined) {
    128     if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid preview budget');
    129     const unlimited = this.unbounded && fuel === undefined;
    130     fuel ??= 1_000_000n;
    131     if (typeof fuel !== 'bigint' || fuel < 0n || fuel > 0xffffffffffffffffn) throw new Error('Invalid preview fuel');
    132     const bytes = unlimited ? this.output('arb_host_preview_unbounded', x, limit) : this.output('arb_host_preview', x, limit, fuel);
    133     return this.decodeSnapshot(bytes);
    134   }
    135   snapshot(x,limit=100000) {
    136     if(!Number.isInteger(limit)||limit<1||limit>100000)throw Error('Invalid snapshot budget');
    137     return this.decodeSnapshot(this.output('arb_host_snapshot_unbounded',x,limit));
    138   }
    139   decodeSnapshot(bytes) {
    140     const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    141     if (view.getUint32(0,true) !== 1) throw new Error('Unsupported preview format');
    142     const nodes = [], count = view.getUint32(8,true);
    143     for (let i=0;i<count;i++) {
    144       const at = 16+i*16, arity=view.getUint32(at+4,true);
    145       nodes.push({id:view.getUint32(at,true),kind:['leaf','stem','fork'][arity],
    146         children:Array.from({length:arity},(_,j)=>view.getUint32(at+8+4*j,true))});
    147     }
    148     return {root:view.getUint32(4,true),nodes,truncated:!!view.getUint32(12,true)};
    149   }
    150   toBytes(x) { return this.output('arb_to_bytes', x); }
    151   output(name, ...args) {
    152     this.call(name, ...args, this.scratch, this.scratch + 8);
    153     const p = this.view().getUint32(this.scratch, true);
    154     const n = this.view().getUint32(this.scratch + 8, true);
    155     try { return new Uint8Array(this.e.memory.buffer, p, n).slice(); }
    156     finally { this.e.arboricx_free_buf(this.ctx, p, n); }
    157   }
    158   stem(x) { return this.handle('arb_stem', x); }
    159   fork(a, b) { return this.handle('arb_fork', a, b); }
    160   app(f, x) { return this.handle('arb_app', f, x); }
    161   apply(f, args) {
    162     return this.scope(({keep}) => {
    163       let result = this.retain(f);
    164       for (const x of args) { const next = this.app(result, x); this.release(result); result = next; }
    165       return keep(result);
    166     });
    167   }
    168   force(x, fuel = undefined) {
    169     return this.unbounded && fuel === undefined ? this.handle('arb_host_reduce_unbounded', x) : this.handle('arb_reduce', x, fuel ?? 1_000_000_000n);
    170   }
    171   release(x) { this.call('arb_release', x); this.owners.get(x)?.delete(x); this.owners.delete(x); }
    172   observe(x) {
    173     const id = this.force(x);
    174     try {
    175       for (const kind of ['leaf', 'stem', 'fork']) {
    176         this.call('arb_is_' + kind, id, this.scratch);
    177         if (!this.view().getInt32(this.scratch, true)) continue;
    178         if (kind === 'leaf') return {node: {kind}};
    179         if (kind === 'stem') return {node: {kind, child: this.handle('arb_get_stem_child', id)}};
    180         this.call('arb_get_fork_children', id, this.scratch, this.scratch + 8);
    181         return {node: {kind, left: this.own(this.view().getBigUint64(this.scratch, true)), right: this.own(this.view().getBigUint64(this.scratch + 8, true))}};
    182       }
    183       throw new Error('Expected constructor');
    184     } finally { this.release(id); }
    185   }
    186   pair(x) { const {node} = this.observe(x); if (node.kind !== 'fork') throw new Error('Expected pair'); return [node.left, node.right]; }
    187   fromTernary(s) {
    188     return this.scope(({keep}) => {
    189       const stack = [];
    190       for (let i = s.length - 1; i >= 0; i--) {
    191         if (s[i] === '0') stack.push(this.retain(this.leaf));
    192         else if (s[i] === '1' && stack.length >= 1) {
    193           const child = stack.pop(); stack.push(this.stem(child)); this.release(child);
    194         } else if (s[i] === '2' && stack.length >= 2) {
    195           const a = stack.pop(), b = stack.pop(); stack.push(this.fork(a,b)); this.release(a); this.release(b);
    196         } else throw new Error('Invalid ternary tree');
    197       }
    198       if (stack.length !== 1) throw new Error('Invalid ternary tree');
    199       return keep(stack[0]);
    200     });
    201   }
    202   toTernary(x, limit = 1_000_000) {
    203     return this.scope(() => {
    204       const pending = [this.retain(x)], result = [];
    205       while (pending.length) {
    206         if (result.length >= limit) throw new Error('Tree expansion limit');
    207         const current = pending.pop(), {node} = this.observe(current);
    208         this.release(current);
    209         if (node.kind === 'leaf') result.push('0');
    210         else if (node.kind === 'stem') { result.push('1'); pending.push(node.child); }
    211         else { result.push('2'); pending.push(node.right, node.left); }
    212       }
    213       return result.join('');
    214     });
    215   }
    216   dispose() {
    217     if (!this.ctx) return;
    218     if (this.scopes.length) throw new Error('Cannot dispose inside a scope');
    219     this.owners.clear();
    220     this.e.arb_host_free(this.scratch, 16);
    221     this.e.arboricx_free(this.ctx); this.ctx = 0;
    222   }
    223 }