io-v2-session.test.mjs (5964B)
1 import test from 'node:test'; 2 import assert from 'node:assert/strict'; 3 import {Graph} from '../dist/graph.js'; 4 import {readText,readNatural,readList} from '../dist/codecs.js'; 5 import {constructors} from '../dist/io-v2-private.js'; 6 import {Wire} from '../dist/io-v2-core.js'; 7 import {Session} from '../dist/io-v2-session.js'; 8 function fixture(budget={}){ 9 const g=new Graph(budget),t=constructors({leaf:g.leaf,observe:g.observe.bind(g),stem:g.stem.bind(g),fork:g.fork.bind(g),app:g.app.bind(g)}),w=new Wire(t,1n); 10 const n=x=>t.natural(BigInt(x)),a=(tag,p=t.leaf)=>t.fork(n(tag),p),request=(group,op,p=t.leaf)=>a(10,t.fork(t.text(group),t.fork(n(1),t.fork(t.text(op),p)))); 11 const ref=key=>w.marker('io.v2.resource',key),read=request('stream','read',t.fork(ref(1),n(4))),write=bytes=>request('stream','write',t.fork(ref(2),w.list(bytes.map(n)))); 12 const seq=(left,right)=>a(1,t.fork(left,t.fork(t.leaf,right))),session=action=>new Session(t,action,'1',roots=>g.collect([...roots])); 13 return {g,t,w,n,a,request,ref,read,write,seq,session}; 14 } 15 async function poll(s,predicate){for(let i=0;i<100;i++){const r=await s.turn({run:'1',kind:'poll'});if(predicate(r))return r;}throw Error('poll did not progress');} 16 const value=(f,s)=>f.w.pair(f.w.pair(s.core.result.value)[1])[0]; 17 test('stream read uses the independent native data-pair encoding, not a nested tuple',async()=>{ 18 const f=fixture(),s=f.session(f.read),r=await poll(s,r=>r.events.length);const event=r.events[0]; 19 await s.turn({run:'1',kind:'reply',reply:{...event,bytes:[65,0,255],eof:false}});if(!s.core.result)await poll(s,r=>r.done); 20 const [tag,bytes]=f.w.pair(value(f,s));assert.equal(readText(f.g,tag),'data');assert.deepEqual(readList(f.g,bytes,3).map(h=>readNatural(f.g,h)),[65n,0n,255n]); 21 }); 22 test('EOF is sticky and standard handles are borrowed, not physically closed',async()=>{ 23 const f=fixture(),s=f.session(f.seq(f.read,f.read)),r=await poll(s,r=>r.events.length); 24 let out=await s.turn({run:'1',kind:'reply',reply:{...r.events[0],bytes:[],eof:true}}); 25 if(!out.done)out=await poll(s,r=>{assert.equal(r.events.length,0);return r.done;}); 26 assert.equal(readText(f.g,readList(f.g,value(f,s),1)[0]),'eof'); 27 const other=fixture(),close=other.session(other.request('resource','close',other.ref(2)));await poll(close,r=>r.done); 28 assert.equal(readText(other.g,readList(other.g,value(other,close),4)[0]),'borrowed-resource'); 29 }); 30 test('structured session stop waits for provider acknowledgement and refuses reuse',async()=>{ 31 const f=fixture(),s=f.session(f.read),r=await poll(s,r=>r.events.length),event=r.events[0]; 32 await assert.rejects(s.turn({run:'2',kind:'stop'}),/stale/); 33 const stopped=await s.turn({run:'1',kind:'stop'});assert(!stopped.done);assert.equal(stopped.events[0].kind,'cancel'); 34 let done=await s.turn({run:'1',kind:'reply',reply:{...event,kind:'cancelled'}});if(!done.done)done=await poll(s,r=>r.done); 35 assert.equal(done.outcome.kind,'cancelled');assert(Object.values(done.counts).every(c=>Object.values(c).every(n=>n===0))); 36 await assert.rejects(s.turn({run:'1',kind:'poll'}),/stale/); 37 }); 38 test('failed output poisons the slot before demanding any later byte payload',async()=>{ 39 const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe; 40 f.t.observe=h=>{if(h===poison)throw Error('forced poisoned write data');return observe(h);}; 41 const later=f.request('stream','write',f.t.fork(f.ref(2),poison)),s=f.session(f.seq(f.write([65]),later)),r=await poll(s,r=>r.events.length); 42 await s.turn({run:'1',kind:'reply',reply:{...r.events[0],kind:'error'}});if(!s.core.result)await poll(s,r=>r.done); 43 assert.equal(s.core.hasFault,false);const error=readList(f.g,value(f,s),4);assert.equal(readText(f.g,error[0]),'invalid-state');assert.equal(readText(f.g,error[3]),'unknown'); 44 }); 45 test('attempted output has an aggregate bound and never silently retries partial writes',async()=>{ 46 const f=fixture({fuel:20000000});let action=f.write([1]);for(let i=0;i<4;i++)action=f.seq(f.write(Array(4096).fill(65)),action); 47 const s=f.session(action);let events=0; 48 for(let i=0;i<100&&!s.core.result;i++){ 49 const r=await s.turn({run:'1',kind:'poll'}); 50 for(const event of r.events){events++;await s.turn({run:'1',kind:'reply',reply:{...event,kind:'write',count:1}});} 51 } 52 assert(s.core.result);assert.equal(events,4);assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-bound'); 53 }); 54 test('named UTF8 facilities preserve nonfinal carry and generic BOM text',async()=>{ 55 const f=fixture(),bytes=[0xef,0xbb,0xbf,65,0xf0,0x9f],s=f.session(f.request('utf8','decode',f.w.list([f.n(0),f.w.list(bytes.map(f.n))]))); 56 await poll(s,r=>r.done);const [text,carry]=readList(f.g,value(f,s),2); 57 assert.equal(readText(f.g,text),'\ufeffA');assert.deepEqual(readList(f.g,carry,2).map(h=>readNatural(f.g,h)),[240n,159n]); 58 const other=fixture(),encoded=other.session(other.request('utf8','encode',other.t.text('λ😀')));await poll(encoded,r=>r.done); 59 assert.deepEqual(readList(other.g,value(other,encoded),6).map(h=>Number(readNatural(other.g,h))),[206,187,240,159,152,128]); 60 }); 61 test('named UTF8 rejects excess heads/tails without demand and malformed scalar values as ordinary errors',async()=>{ 62 for(const [op,limit]of [['encode',1024],['decode',4096]]){ 63 const f=fixture({fuel:20000000}),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe; 64 f.t.observe=h=>{if(h===poison)throw Error('forced excess payload');return observe(h);}; 65 let list=f.t.fork(poison,poison);for(let i=0;i<limit;i++)list=f.t.fork(f.n(65),list); 66 const s=f.session(f.request('utf8',op,op==='encode'?list:f.w.list([f.n(0),list])));await poll(s,r=>r.done); 67 assert(!s.core.hasFault);assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-argument'); 68 } 69 for(const scalar of [0xd800,0x110000]){ 70 const f=fixture(),s=f.session(f.request('utf8','encode',f.w.list([f.n(scalar)])));await poll(s,r=>r.done); 71 assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-argument'); 72 } 73 });