io-resources.ts (9547B)
1 import { Tasks, type Token, type Diagnostics } from './io-tasks.js'; 2 export interface Flags { readonly borrowed?: boolean; readonly internal?: boolean; readonly duplex?: boolean; } 3 export interface Resource<B> { 4 readonly owner: number; readonly kind: string; readonly backend: B; readonly flags: Flags; 5 readonly busy: number; readonly closing: boolean; readonly releasing: boolean; 6 } 7 type Entry<B> = { -readonly [K in keyof Resource<B>]: Resource<B>[K] }; 8 export type Access = 'exclusive' | 'read' | 'write'; 9 // State only. Construct via lifecycle() to bind task settlement to registry guards. 10 // Single owning event loop; host effects occur AFTER admission/lease/close claims. 11 class Resources<P, B> { 12 private next = 4; 13 private readonly resources = new Map<number, Entry<B>>(); 14 private readonly admissions = new Map<number, number>(); 15 private readonly leases = new Map<number, { scope: number; resource: number; mask: number }>(); 16 constructor(private readonly tasks: Tasks<P>, standard: readonly [B, B, B], private readonly limit: number) { 17 if (!Number.isSafeInteger(limit) || limit < 3) throw new Error('invalid resource identity limit'); 18 standard.forEach((backend, index) => this.resources.set(index + 1, { 19 owner: 0, kind: index === 0 ? 'reader' : 'writer', backend, flags: Object.freeze({ borrowed: true }), 20 busy: 0, closing: false, releasing: false })); 21 } 22 get counts() { return { resources: this.resources.size, admissions: this.admissions.size, leases: this.leases.size }; } 23 private token(key: number): Token { return Object.freeze({ run: this.tasks.identity, key }); } 24 private key(token: Token): number { 25 if (token.run !== this.tasks.identity) throw new Error('stale-handle'); 26 return token.key; 27 } 28 private room(count: number): void { 29 if (count > this.limit - this.next + 1) throw new Error('resource-identity-exhausted'); 30 } 31 private open(scope: number): void { 32 const state = this.tasks.state(scope); 33 if (state.phase !== 'open' || state.reason) throw new Error('closed-scope'); 34 } 35 private entry(current: number, token: Token): Entry<B> { 36 const resource = this.resources.get(this.key(token)); 37 if (!resource) throw new Error('stale-handle'); 38 if (!this.tasks.within(current, resource.owner)) throw new Error('out-of-scope'); 39 return resource; 40 } 41 lookup(current: number, token: Token): Resource<B> { return { ...this.entry(current, token) }; } 42 beginAcquire(current: number, owner = current): Token { 43 this.tasks.checkpoint(current); this.open(owner); 44 if (!this.tasks.within(current, owner)) throw new Error('out-of-scope'); 45 this.room(1); const token = this.token(this.next++); 46 this.admissions.set(token.key, owner); return token; 47 } 48 abortAcquire(token: Token): void { 49 if (!this.admissions.delete(this.key(token))) throw new Error('invalid-admission'); 50 } 51 // An admitted acquisition can finish after owner stop/admission closure. 52 // Publication failure leaves the admission live: release raw backend, then abort. 53 publish(admission: Token, kind: string, backend: B, flags: Flags = {}): Token { 54 const key = this.key(admission), owner = this.admissions.get(key); 55 if (owner === undefined) throw new Error('invalid-admission'); 56 if (!kind || Object.values(flags).some(value => typeof value !== 'boolean')) throw new Error('invalid-argument'); 57 this.room(1); 58 const savedFlags = Object.freeze({ ...flags }), token = this.token(this.next); 59 this.resources.set(token.key, { owner, kind, backend, flags: savedFlags, busy: 0, closing: false, releasing: false }); 60 this.next++; this.admissions.delete(key); return token; 61 } 62 // All recoverable validation and preparation precede task admission. No host 63 // callback/await separates admission from rekeying. JS OOM is fatal, not rollback. 64 spawn(owner: number, supervised: boolean, keep: boolean, tokens: readonly Token[]) { 65 this.tasks.checkpoint(owner); this.open(owner); 66 if (typeof supervised !== 'boolean' || typeof keep !== 'boolean') throw new Error('invalid-argument'); 67 if (tokens.length > 256) throw new Error('transfer-limit'); 68 const seen = new Set<number>(); 69 const entries = tokens.map(token => { 70 const resource = this.entry(owner, token); 71 if (seen.has(token.key)) throw new Error('duplicate-handle'); 72 seen.add(token.key); 73 if (resource.owner !== owner) throw new Error('not-owner'); 74 if (resource.flags.borrowed) throw new Error('borrowed-resource'); 75 if (resource.flags.internal) throw new Error('internal-resource'); 76 if (resource.closing) throw new Error('closed-handle'); 77 if (resource.busy) throw new Error('busy'); 78 return { old: token.key, resource }; 79 }).sort((a, b) => a.old - b.old); 80 this.room(tokens.length); 81 const fresh = new Map(entries.map((entry, i) => [entry.old, this.token(this.next + i)])); 82 const handles = tokens.map(token => fresh.get(token.key)!); 83 const task = this.tasks.spawn(owner, supervised, keep); 84 for (const entry of entries) { 85 this.resources.delete(entry.old); entry.resource.owner = task.key; 86 this.resources.set(fresh.get(entry.old)!.key, entry.resource); 87 } 88 this.next += tokens.length; 89 return { task, handles }; 90 } 91 beginLease(current: number, token: Token, kind: string, access: Access, internal = false) { 92 this.tasks.checkpoint(current); 93 const resource = this.entry(current, token); 94 if (resource.kind !== kind) throw new Error('wrong-kind'); 95 if (resource.closing) throw new Error('closed-handle'); 96 if (resource.flags.internal && !internal) throw new Error('internal-resource'); 97 if (!['exclusive', 'read', 'write'].includes(access)) throw new Error('invalid-argument'); 98 const mask = !resource.flags.duplex || access === 'exclusive' ? 3 : access === 'read' ? 1 : 2; 99 if (resource.busy & mask) throw new Error('busy'); 100 this.room(1); const lease = this.token(this.next++); 101 this.leases.set(lease.key, { scope: current, resource: token.key, mask }); resource.busy |= mask; 102 return { token: lease, backend: resource.backend }; 103 } 104 endLease(token: Token): void { 105 const key = this.key(token), lease = this.leases.get(key); 106 if (!lease) throw new Error('invalid-lease'); 107 this.resources.get(lease.resource)!.busy &= ~lease.mask; this.leases.delete(key); 108 } 109 claimClose(current: number, token: Token, settling = false): Resource<B> | null { 110 const resource = this.entry(current, token); 111 if (settling) { 112 if (resource.owner !== current || this.tasks.state(current).phase !== 'releasing') throw new Error('invalid-close'); 113 } else { 114 this.tasks.checkpoint(current); 115 if (resource.flags.internal) throw new Error('internal-resource'); 116 if (resource.flags.borrowed) throw new Error('borrowed-resource'); 117 } 118 resource.closing = true; 119 if (resource.busy || resource.releasing) return null; 120 resource.releasing = true; return { ...resource }; 121 } 122 // Once, including failed release. Never retry a physically ambiguous close. 123 finishClose(token: Token): void { 124 const key = this.key(token), resource = this.resources.get(key); 125 if (!resource) throw new Error('stale-handle'); 126 if (!resource.releasing) throw new Error('invalid-close'); 127 this.resources.delete(key); 128 } 129 newestOwned(scope: number): Token | null { 130 let newest = -1; 131 for (const [key, resource] of this.resources) if (resource.owner === scope && key > newest) newest = key; 132 return newest < 0 ? null : this.token(newest); 133 } 134 readyForRelease(scope: number): boolean { 135 return ![...this.admissions.values()].includes(scope) && ![...this.leases.values()].some(lease => lease.scope === scope); 136 } 137 finishScope(scope: number): void { 138 if (!this.readyForRelease(scope) || this.newestOwned(scope)) throw new Error('busy'); 139 } 140 // Use inside HostWork.release, after Tasks.beginRelease. Each provider is 141 // attempted once, sequentially in reverse publication order; borrowed backends 142 // stay open. Diagnostics construction failure must not skip remaining releases. 143 async releaseScope(scope: number, release: (resource: Resource<B>) => void | Promise<void>, 144 problem: (error: unknown) => P, fallback: P): Promise<Diagnostics<P>> { 145 if (this.tasks.state(scope).phase !== 'releasing') throw new Error('invalid-transition'); 146 const items: P[] = []; let omitted = 0; 147 for (let token = this.newestOwned(scope); token; token = this.newestOwned(scope)) { 148 const resource = this.claimClose(scope, token, true); 149 if (!resource) throw new Error('busy'); 150 try { if (!resource.flags.borrowed) await release(resource); } 151 catch (error) { 152 if (items.length === 16) omitted++; 153 else { 154 let value = fallback; try { value = problem(error); } catch { /* retain prebuilt fallback */ } 155 items.push(value); 156 } 157 } finally { this.finishClose(token); } 158 } 159 return { items, omitted }; 160 } 161 } 162 export function lifecycle<P, B>(identity: bigint, ownerStopped: P, cycle: P, standard: readonly [B, B, B], 163 limits: { taskIdentities?: number; resourceIdentities?: number } = {}) { 164 let resources!: Resources<P, B>; 165 const tasks = new Tasks(identity, ownerStopped, cycle, limits.taskIdentities ?? Number.MAX_SAFE_INTEGER, { 166 readyForRelease: scope => resources.readyForRelease(scope), finishScope: scope => resources.finishScope(scope) }); 167 resources = new Resources(tasks, standard, limits.resourceIdentities ?? Number.MAX_SAFE_INTEGER); 168 return { tasks, resources }; 169 }