io-host-work.ts (4302B)
1 import { Tasks, hostKey, type HostToken } from './io-tasks.js'; 2 export type Reply<T> = { readonly token: HostToken; readonly outcome: 3 { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown } }; 4 // Adapter-side asynchronous work. Starting one callback never waits for it, so 5 // sibling actions/cancellation can proceed. The provider must settle its promise 6 // AFTER cancellation cleanup; abort delivery alone is never an acknowledgement. 7 export class HostWork<P, T> { 8 private readonly active = new Map<string, { token: HostToken; controller: AbortController }>(); 9 private readonly replies = new Map<string, Reply<T>>(); 10 private notification: { promise: Promise<void>; resolve: () => void } | null = null; 11 constructor(private readonly tasks: Tasks<P>) {} 12 start(scope: number, operation: (signal: AbortSignal) => T | Promise<T>): HostToken { 13 return this.register(this.tasks.beginHost(scope), operation); 14 } 15 // Cleanup is not cancelled by a new stop request. Its result (including a 16 // failure) must still reach the driver to preserve primary/secondary outcomes. 17 release(scope: number, operation: () => T | Promise<T>): HostToken { 18 return this.register(this.tasks.beginReleaseHost(scope), operation); 19 } 20 private register(token: HostToken, operation: (signal: AbortSignal) => T | Promise<T>): HostToken { 21 const controller = new AbortController(); 22 this.active.set(hostKey(token), { token, controller }); 23 const complete = (outcome: Reply<T>['outcome']) => { 24 // Settle first; a cancelled reply is discarded without observing its value. 25 const disposition = this.tasks.endHost(token); 26 this.active.delete(hostKey(token)); 27 if (disposition === 'reply') this.replies.set(hostKey(token), { token, outcome }); 28 const notice = this.notification; this.notification = null; notice?.resolve(); 29 }; 30 try { Promise.resolve(operation(controller.signal)).then( 31 value => complete({ ok: true, value }), error => complete({ ok: false, error })); } 32 catch (error) { complete({ ok: false, error }); } 33 return token; 34 } 35 private usable(token: HostToken): boolean { 36 if (!this.tasks.within(token.scope, token.scope)) return false; 37 const state = this.tasks.state(token.scope); 38 return token.kind === 'release' ? state.phase === 'releasing' : state.phase === 'open' && state.reason === null; 39 } 40 // Resource close may interrupt one operation without cancelling its task. 41 // The resulting provider reply still reaches that body as an ordinary error. 42 // Never abort a cleanup batch or acknowledge work merely by sending this signal. 43 cancelBody(token: HostToken): boolean { 44 const work = this.active.get(hostKey(token)); 45 if (token.kind !== 'body' || !work || work.token.run !== token.run || 46 work.token.scope !== token.scope || work.controller.signal.aborted) return false; 47 work.controller.abort(); return true; 48 } 49 cancelStopped(): void { 50 for (const token of this.tasks.takeCancellations()) { 51 const work = this.active.get(hostKey(token)); 52 if (!work || work.token.scope !== token.scope) throw new Error('host work ownership mismatch'); 53 work.controller.abort(); 54 } 55 // Completion may have won the race with cancellation. Drop undelivered data 56 // too; a queued reply cannot revive a stopped/settled body. 57 for (const [key, reply] of this.replies) if (!this.usable(reply.token)) this.replies.delete(key); 58 } 59 take(): Reply<T> | null { 60 for (const [key, reply] of this.replies) { 61 this.replies.delete(key); 62 if (this.usable(reply.token)) return reply; 63 } 64 return null; 65 } 66 wait(): Promise<void> { 67 if (this.replies.size || !this.active.size) return Promise.resolve(); 68 if (!this.notification) { 69 let resolve!: () => void; 70 const promise = new Promise<void>(done => { resolve = done; }); 71 this.notification = { promise, resolve }; 72 } 73 return this.notification.promise; 74 } 75 get counts() { return { pending: this.active.size, replies: this.replies.size }; } 76 // Reply data roots are the embedding's responsibility until taken/discarded. 77 *values(): Iterable<T> { 78 for (const reply of this.replies.values()) if (this.usable(reply.token) && reply.outcome.ok) yield reply.outcome.value; 79 } 80 }