tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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 }