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 }