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 }