tricu

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

runtime.mjs (10425B)


      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() !== 2) throw new Error('ABI v2 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   retain(x) { return this.handle('arb_retain', x); }
     76   collect() { this.call('arb_collect'); }
     77   stats() {
     78     this.call('arb_host_stats', this.scratch, this.scratch + 4, this.scratch + 8, this.scratch + 12);
     79     const v = this.view();
     80     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)};
     81   }
     82   buffer(bytes, fn) {
     83     if (!this.ctx) throw new Error('Disposed WASM runtime');
     84     const p = this.e.arb_host_alloc(Math.max(1, bytes.length));
     85     if (!p) { this.poisoned = true; throw new Error('WASM buffer allocation failed'); }
     86     try { new Uint8Array(this.e.memory.buffer, p, bytes.length).set(bytes); return fn(p); }
     87     finally { this.e.arb_host_free(p, Math.max(1, bytes.length)); }
     88   }
     89   load(bytes, name) {
     90     if (name !== undefined && (typeof name !== 'string' || name.includes('\0'))) throw new Error('Invalid export name');
     91     return this.buffer(bytes, p => name === undefined
     92       ? this.handle('arb_load_bundle_default', p, bytes.length)
     93       : this.buffer(new TextEncoder().encode(name + '\0'), n => this.handle('arb_load_bundle', p, bytes.length, n)));
     94   }
     95   ofBytes(bytes) { return this.buffer(bytes, p => this.handle('arb_of_bytes', p, bytes.length)); }
     96   textLimit(limit) {
     97     if (!Number.isSafeInteger(limit) || limit < 0 || limit > 0xffffffff) throw new Error('Invalid scalar limit');
     98   }
     99   ofText(value, limit = 4096) {
    100     this.textLimit(limit);
    101     if (typeof value !== 'string' || value.length > limit * 2) throw new Error('Text limit/type');
    102     let count = 0;
    103     for (const c of value) {
    104       const scalar = c.codePointAt(0);
    105       if (scalar >= 0xd800 && scalar <= 0xdfff) throw new Error('Unpaired Unicode surrogate');
    106       if (++count > limit) throw new Error('Text limit');
    107     }
    108     const bytes = new TextEncoder().encode(value);
    109     return this.buffer(bytes, p => this.handle('arb_host_of_text', p, bytes.length, limit));
    110   }
    111   toText(x, limit = 4096) {
    112     this.textLimit(limit);
    113     // Preserve an initial U+FEFF; this is text, not a source-file BOM policy.
    114     return new TextDecoder('utf-8', {fatal:true, ignoreBOM:true}).decode(this.output('arb_host_to_text', x, limit));
    115   }
    116   // Labels are snapshot-local numbers, not owned handles or live node IDs.
    117   preview(x, limit = 64, fuel = undefined) {
    118     if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid preview budget');
    119     const unlimited = this.unbounded && fuel === undefined;
    120     fuel ??= 1_000_000n;
    121     if (typeof fuel !== 'bigint' || fuel < 0n || fuel > 0xffffffffffffffffn) throw new Error('Invalid preview fuel');
    122     const bytes = unlimited ? this.output('arb_host_preview_unbounded', x, limit) : this.output('arb_host_preview', x, limit, fuel);
    123     return this.decodeSnapshot(bytes);
    124   }
    125   snapshot(x,limit=100000) {
    126     if(!Number.isInteger(limit)||limit<1||limit>100000)throw Error('Invalid snapshot budget');
    127     return this.decodeSnapshot(this.output('arb_host_snapshot_unbounded',x,limit));
    128   }
    129   decodeSnapshot(bytes) {
    130     const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    131     if (view.getUint32(0,true) !== 1) throw new Error('Unsupported preview format');
    132     const nodes = [], count = view.getUint32(8,true);
    133     for (let i=0;i<count;i++) {
    134       const at = 16+i*16, arity=view.getUint32(at+4,true);
    135       nodes.push({id:view.getUint32(at,true),kind:['leaf','stem','fork'][arity],
    136         children:Array.from({length:arity},(_,j)=>view.getUint32(at+8+4*j,true))});
    137     }
    138     return {root:view.getUint32(4,true),nodes,truncated:!!view.getUint32(12,true)};
    139   }
    140   toBytes(x) { return this.output('arb_to_bytes', x); }
    141   output(name, ...args) {
    142     this.call(name, ...args, this.scratch, this.scratch + 8);
    143     const p = this.view().getUint32(this.scratch, true);
    144     const n = this.view().getUint32(this.scratch + 8, true);
    145     try { return new Uint8Array(this.e.memory.buffer, p, n).slice(); }
    146     finally { this.e.arboricx_free_buf(this.ctx, p, n); }
    147   }
    148   stem(x) { return this.handle('arb_stem', x); }
    149   fork(a, b) { return this.handle('arb_fork', a, b); }
    150   app(f, x) { return this.handle('arb_app', f, x); }
    151   apply(f, args) {
    152     return this.scope(({keep}) => {
    153       let result = this.retain(f);
    154       for (const x of args) { const next = this.app(result, x); this.release(result); result = next; }
    155       return keep(result);
    156     });
    157   }
    158   force(x, fuel = undefined) {
    159     return this.unbounded && fuel === undefined ? this.handle('arb_host_reduce_unbounded', x) : this.handle('arb_reduce', x, fuel ?? 1_000_000_000n);
    160   }
    161   release(x) { this.call('arb_release', x); this.owners.get(x)?.delete(x); this.owners.delete(x); }
    162   observe(x) {
    163     const id = this.force(x);
    164     try {
    165       for (const kind of ['leaf', 'stem', 'fork']) {
    166         this.call('arb_is_' + kind, id, this.scratch);
    167         if (!this.view().getInt32(this.scratch, true)) continue;
    168         if (kind === 'leaf') return {node: {kind}};
    169         if (kind === 'stem') return {node: {kind, child: this.handle('arb_get_stem_child', id)}};
    170         this.call('arb_get_fork_children', id, this.scratch, this.scratch + 8);
    171         return {node: {kind, left: this.own(this.view().getBigUint64(this.scratch, true)), right: this.own(this.view().getBigUint64(this.scratch + 8, true))}};
    172       }
    173       throw new Error('Expected constructor');
    174     } finally { this.release(id); }
    175   }
    176   pair(x) { const {node} = this.observe(x); if (node.kind !== 'fork') throw new Error('Expected pair'); return [node.left, node.right]; }
    177   fromTernary(s) {
    178     return this.scope(({keep}) => {
    179       const stack = [];
    180       for (let i = s.length - 1; i >= 0; i--) {
    181         if (s[i] === '0') stack.push(this.retain(this.leaf));
    182         else if (s[i] === '1' && stack.length >= 1) {
    183           const child = stack.pop(); stack.push(this.stem(child)); this.release(child);
    184         } else if (s[i] === '2' && stack.length >= 2) {
    185           const a = stack.pop(), b = stack.pop(); stack.push(this.fork(a,b)); this.release(a); this.release(b);
    186         } else throw new Error('Invalid ternary tree');
    187       }
    188       if (stack.length !== 1) throw new Error('Invalid ternary tree');
    189       return keep(stack[0]);
    190     });
    191   }
    192   toTernary(x, limit = 1_000_000) {
    193     return this.scope(() => {
    194       const pending = [this.retain(x)], result = [];
    195       while (pending.length) {
    196         if (result.length >= limit) throw new Error('Tree expansion limit');
    197         const current = pending.pop(), {node} = this.observe(current);
    198         this.release(current);
    199         if (node.kind === 'leaf') result.push('0');
    200         else if (node.kind === 'stem') { result.push('1'); pending.push(node.child); }
    201         else { result.push('2'); pending.push(node.right, node.left); }
    202       }
    203       return result.join('');
    204     });
    205   }
    206   dispose() {
    207     if (!this.ctx) return;
    208     if (this.scopes.length) throw new Error('Cannot dispose inside a scope');
    209     this.owners.clear();
    210     this.e.arb_host_free(this.scratch, 16);
    211     this.e.arboricx_free(this.ctx); this.ctx = 0;
    212   }
    213 }