io-v2-tasks.js (13181B)
1 export const hostKey = (token) => `${token.kind}:${token.key}`; 2 const empty = () => ({ items: [], omitted: 0 }); 3 const primary = (outcome) => ({ kind: outcome.kind, value: outcome.value }); 4 function append(left, right) { 5 if (!Number.isSafeInteger(right.omitted) || right.omitted < 0) 6 throw new Error('invalid diagnostics'); 7 const available = 16 - left.items.length; 8 return { items: [...left.items, ...right.items.slice(0, available)], 9 omitted: Math.min(Number.MAX_SAFE_INTEGER, left.omitted + right.omitted + Math.max(0, right.items.length - available)) }; 10 } 11 const capture = (outcome) => { 12 if (!['completed', 'failed', 'cancelled'].includes(outcome.kind)) 13 throw new Error('invalid outcome'); 14 return { ...primary(outcome), cleanup: append(empty(), outcome.cleanup ?? empty()) }; 15 }; 16 export class Tasks { 17 identity; 18 ownerStopped; 19 cycle; 20 identityLimit; 21 resources; 22 groups = new Map(); 23 tasks = new Map(); 24 hosts = new Map(); 25 nextScope = 1; 26 nextHost = 1; 27 constructor(identity, ownerStopped, cycle, identityLimit = Number.MAX_SAFE_INTEGER, resources) { 28 this.identity = identity; 29 this.ownerStopped = ownerStopped; 30 this.cycle = cycle; 31 this.identityLimit = identityLimit; 32 this.resources = resources; 33 if (typeof identity !== 'bigint' || identity <= 0n || identity > 0xffffffffffffffffn || 34 !Number.isSafeInteger(identityLimit) || identityLimit < 1) 35 throw new Error('invalid run identity or limit'); 36 this.groups.set(0, this.group(null, 0, 'root', false)); 37 } 38 group(parent, task, kind, propagate) { 39 return { parent, task, kind, propagate, phase: 'open', reason: null, body: null, 40 cleanup: empty(), lexical: null, wait: null, hostCalls: new Set(), releaseStarted: false }; 41 } 42 get(scope) { 43 const group = this.groups.get(scope); 44 if (!group) 45 throw new Error('closed-scope'); 46 return group; 47 } 48 // Read-only copies: embeddings cannot mutate lifecycle state or diagnostic arrays. 49 state(scope) { 50 const g = this.get(scope); 51 return { parent: g.parent, task: g.task, kind: g.kind, phase: g.phase, 52 reason: g.reason && primary(g.reason), lexical: g.lexical, wait: g.wait, hostCalls: g.hostCalls.size }; 53 } 54 get counts() { return { scopes: this.groups.size, tasks: this.tasks.size, hosts: this.hosts.size }; } 55 scopeIds() { return [...this.groups.keys()]; } 56 within(current, owner) { 57 for (let scope = current; scope !== null;) { 58 const g = this.groups.get(scope); 59 if (!g) 60 return false; 61 if (scope === owner) 62 return true; 63 scope = g.parent; 64 } 65 return false; 66 } 67 checkpoint(scope) { 68 const g = this.get(scope); 69 if (g.reason) 70 throw new Error('stopped'); 71 if (g.phase !== 'open' || g.lexical !== null || g.wait !== null) 72 throw new Error('invalid-transition'); 73 } 74 scopeId() { 75 if (this.nextScope > this.identityLimit) 76 throw new Error('scope-identity-exhausted'); 77 return this.nextScope++; 78 } 79 nested(parent) { 80 this.checkpoint(parent); 81 const id = this.scopeId(), child = this.group(parent, this.get(parent).task, 'lexical', true); 82 this.groups.set(id, child); 83 this.get(parent).lexical = id; 84 return id; 85 } 86 spawn(owner, supervised, keep) { 87 this.checkpoint(owner); 88 if (typeof supervised !== 'boolean' || typeof keep !== 'boolean') 89 throw new Error('invalid-argument'); 90 const id = this.scopeId(), child = this.group(owner, id, 'spawned', !supervised); 91 this.groups.set(id, child); 92 this.tasks.set(id, { owner, supervised, ticket: keep, waiter: null, outcome: null }); 93 return { run: this.identity, key: id }; 94 } 95 recordStop(scope, reason) { 96 const g = this.groups.get(scope); 97 if (!g || g.reason) 98 return false; 99 g.reason = primary(reason); 100 return true; 101 } 102 stop(scope, reason) { 103 if (reason.kind !== 'failed' && reason.kind !== 'cancelled') 104 throw new Error('invalid stop'); 105 if (!this.recordStop(scope, reason)) 106 return; 107 // Fixed point propagates first failure upward and owner cancellation downward. 108 for (let changed = true; changed;) { 109 changed = false; 110 for (const [id, g] of this.groups) 111 if (g.parent !== null) { 112 if (g.reason?.kind === 'failed' && g.propagate) 113 changed = this.recordStop(g.parent, g.reason) || changed; 114 if (this.get(g.parent).reason) 115 changed = this.recordStop(id, { kind: 'cancelled', value: this.ownerStopped }) || changed; 116 } 117 } 118 } 119 checked(current, token) { 120 if (token.run !== this.identity) 121 throw new Error('stale-task'); 122 const task = this.tasks.get(token.key); 123 if (!task) 124 throw new Error('stale-task'); 125 if (!this.within(current, task.owner)) 126 throw new Error('out-of-scope'); 127 return task; 128 } 129 // Edges are task identities, not lexical scope IDs. Normal scope exit adds 130 // implicit edges to unfinished children; explicit join cycle checks include them. 131 reaches(start, wanted) { 132 const pending = [start], seen = new Set(); 133 while (pending.length) { 134 const task = pending.pop(); 135 if (task === wanted) 136 return true; 137 if (seen.has(task)) 138 continue; 139 seen.add(task); 140 for (const [id, g] of this.groups) 141 if (g.task === task) { 142 if (g.wait !== null) 143 pending.push(g.wait); 144 if (g.phase === 'joining') 145 for (const [child, t] of this.tasks) 146 if (t.owner === id && t.outcome === null) 147 pending.push(child); 148 } 149 } 150 return false; 151 } 152 claimJoin(current, token) { 153 this.checkpoint(current); 154 const task = this.checked(current, token); 155 if (!task.ticket) 156 throw new Error('stale-task'); 157 if (this.within(current, token.key) || this.reaches(token.key, this.get(current).task)) 158 throw new Error('join-cycle'); 159 task.ticket = false; 160 task.waiter = current; 161 this.get(current).wait = token.key; 162 } 163 pollJoin(current) { 164 const g = this.get(current); 165 if (g.wait === null) 166 throw new Error('invalid-transition'); 167 const task = this.tasks.get(g.wait); 168 if (!task.outcome) 169 return null; 170 const outcome = capture(task.outcome); 171 this.tasks.delete(g.wait); 172 g.wait = null; 173 return outcome; 174 } 175 abandonJoin(current) { 176 const g = this.groups.get(current); 177 if (!g || g.wait === null) 178 return; 179 const task = this.tasks.get(g.wait); 180 task.waiter = null; 181 if (task.outcome) 182 this.tasks.delete(g.wait); 183 g.wait = null; 184 } 185 discard(current, token) { 186 this.checkpoint(current); 187 const task = this.checked(current, token); 188 if (!task.ticket) 189 throw new Error('stale-task'); 190 task.ticket = false; 191 if (task.outcome) 192 this.tasks.delete(token.key); 193 } 194 cancel(current, token, reason) { 195 this.checkpoint(current); 196 const task = this.checked(current, token); 197 if (task.outcome) 198 throw new Error('stale-task'); 199 this.stop(token.key, { kind: 'cancelled', value: reason }); 200 } 201 bodyDone(scope, outcome) { 202 const g = this.get(scope), captured = capture(outcome); 203 if (g.phase !== 'open' || g.lexical !== null) 204 throw new Error('invalid-transition'); 205 this.abandonJoin(scope); 206 if (captured.kind !== 'completed') 207 this.stop(scope, captured); 208 g.body = captured; 209 for (const [id, t] of this.tasks) 210 if (t.owner === scope && !t.outcome && this.reaches(id, g.task)) { 211 this.stop(scope, { kind: 'failed', value: this.cycle }); 212 break; 213 } 214 g.phase = 'joining'; 215 } 216 beginHost(scope) { 217 this.checkpoint(scope); 218 return this.registerHost(scope, 'body'); 219 } 220 beginReleaseHost(scope) { 221 const group = this.get(scope); 222 if (group.phase !== 'releasing' || group.releaseStarted) 223 throw new Error('invalid-transition'); 224 // One cleanup batch per scope uses its already-reserved identity, in a 225 // separate namespace. Body-call identity exhaustion cannot prevent cleanup. 226 const token = this.registerHost(scope, 'release'); 227 group.releaseStarted = true; 228 return token; 229 } 230 registerHost(scope, kind) { 231 if (kind === 'body' && this.nextHost > this.identityLimit) 232 throw new Error('host-identity-exhausted'); 233 const token = Object.freeze({ run: this.identity, key: kind === 'body' ? this.nextHost++ : scope, scope, kind }); 234 const key = hostKey(token); 235 this.hosts.set(key, { token, cancelling: false }); 236 this.get(scope).hostCalls.add(key); 237 return token; 238 } 239 // Delivery is separate from acknowledgement. Issuing AbortController.abort() 240 // cannot settle a task whose host callback/lease is still outstanding. 241 takeCancellations() { 242 const pending = []; 243 for (const call of this.hosts.values()) 244 if (call.token.kind === 'body' && !call.cancelling && this.get(call.token.scope).reason) { 245 call.cancelling = true; 246 pending.push(call.token); 247 } 248 return pending; 249 } 250 endHost(token) { 251 const key = hostKey(token), call = this.hosts.get(key); 252 if (token.run !== this.identity || !call || call.token.scope !== token.scope || call.token.kind !== token.kind) 253 return 'stale'; 254 const g = this.get(token.scope); 255 this.hosts.delete(key); 256 g.hostCalls.delete(key); 257 return token.kind === 'release' || !g.reason && !g.body ? 'reply' : 'ignored'; 258 } 259 beginRelease(scope) { 260 const g = this.get(scope); 261 if (g.phase !== 'joining') 262 throw new Error('invalid-transition'); 263 if (g.hostCalls.size || [...this.tasks.values()].some(t => t.owner === scope && !t.outcome) || 264 this.resources && !this.resources.readyForRelease(scope)) 265 return false; 266 g.phase = 'releasing'; 267 return true; 268 } 269 // Call only after the embedding has physically released this scope's resources. 270 settle(scope, ownCleanup = empty()) { 271 const g = this.get(scope); 272 if (g.phase !== 'releasing' || !g.body || g.hostCalls.size) 273 throw new Error('invalid-transition'); 274 let final = primary(g.reason ?? g.body); 275 let errors = append(append(g.body.cleanup ?? empty(), g.cleanup), ownCleanup); 276 if (final.kind === 'completed' && errors.items.length) { 277 final = { kind: 'failed', value: errors.items[0] }; 278 errors = { items: errors.items.slice(1), omitted: errors.omitted }; 279 } 280 this.resources?.finishScope(scope); 281 final = { ...final, cleanup: errors }; 282 if (final.kind === 'failed') 283 this.stop(scope, final); 284 for (const [id, task] of this.tasks) 285 if (task.owner === scope) 286 this.tasks.delete(id); 287 if (g.parent !== null) { 288 const parent = this.get(g.parent); 289 if (g.kind === 'lexical') { 290 parent.cleanup = append(parent.cleanup, errors); 291 parent.lexical = null; 292 } 293 else { 294 const task = this.tasks.get(scope); 295 if (!task.supervised) 296 parent.cleanup = append(parent.cleanup, errors); 297 task.outcome = final; 298 if (!task.ticket && task.waiter === null) 299 this.tasks.delete(scope); 300 } 301 } 302 this.groups.delete(scope); 303 return g.kind === 'lexical' ? primary(final) : capture(final); 304 } 305 // Evaluator roots, in addition to embedding-owned bodies, continuations and 306 // pending host arguments. No task/result history remains after consumption. 307 *payloads() { 308 yield this.ownerStopped; 309 yield this.cycle; 310 for (const g of this.groups.values()) { 311 if (g.reason) 312 yield g.reason.value; 313 if (g.body) { 314 yield g.body.value; 315 yield* g.body.cleanup?.items ?? []; 316 } 317 yield* g.cleanup.items; 318 } 319 for (const task of this.tasks.values()) 320 if (task.outcome) { 321 yield task.outcome.value; 322 yield* task.outcome.cleanup?.items ?? []; 323 } 324 } 325 }