Files
Endoleap/src/lib/canvas-runtime/scheduler.ts
T

356 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 从 ledger 派生执行顺序的调度器:ready FIFO+queued 去重、依赖索引、canvas claims 与重试 min-heap。
* 所有权威变化通过 options.mutate 写回 Root;队列本身既不是历史,也不保存另一套任务状态。
* 发送前等待宿主持久化精确 body/key 和 dispatch 标记,结果协调及持久化成功后才释放占用。
* claims/holds 只约束本 scheduler;跨标签、进程或服务端的 writer/身份隔离由 engine 与 storage 端口负责。
*/
import type { Id, Json, OperationIntent } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts';
import { createLedger, type FrozenRequest, type LedgerSnapshot, type Outcome } from './ledger.ts';
import { createDeque, createMinHeap } from './structures.ts';
/** 端口解释后的执行状态;blocked 表示需显式恢复,canonical 是尚待协调校验的回执数据。 */
export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json; readonly blocked?: boolean };
/** 当前可变意图编译出的 wire 计划,交 ledger.freeze 捕获后才成为不可更改的 FrozenRequest。 */
export type CompiledRequest = { readonly kind?: FrozenRequest['kind']; readonly ownerCanvasId?: Id; readonly runId?: Id; readonly protocolVersion?: number; readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>> };
/** 可注入时钟和 timer 端口,便于确定性测试重试/时钟变化;默认 now 使用 Date.now。 */
export type Clock = { now(): number; setTimer(callback: () => void, delay: number): unknown; clearTimer(handle: unknown): void };
/** 默认平台计时适配,定时器只负责唤醒,pump 仍检查当前账本和到期时间。 */
const browserClock: Clock = { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>) };
/** mutate 回调内使用的临时可变账本接口,不直接暴露其内部表。 */
type Ledger = ReturnType<typeof createLedger>;
/** engine 提供准入、纯编译、原子账本更新、持久屏障、外部端口和完成协调。
* complete 必须连回执/基线/working/ledger 一起处理,不允许仅把请求先标成功。 */
export type SchedulerOptions = {
/** false 进入准入等待集合,由 refreshAdmission 唤醒;不在此把逻辑操作改为 blocked。 */
admit?: (intent:OperationIntent)=>boolean;
/** 每次准入/唤醒读取当前权威快照,不信任入队时捕获的旧状态。 */
getLedger(): LedgerSnapshot;
/** 在 engine 的受控发布路径内修改账本,失败不能留下半份持久事实。 */
mutate(reduce: (ledger: Ledger) => void): void;
/** 等待当前 Root 可靠存储;失败保持请求身份与占用,不退回生成新请求。
* scheduler 信任此 Promise 的持久承诺,本身不核对 durableGeneration/底层事务。 */
persist(): Promise<void>;
/** 为新冻结请求分配 ID;恢复/重试沿用原 ID,不再次调用此工厂。 */
createId(): Id;
/** 从当前意图推导需要 claim 的 canvas;编译结果必须覆盖同一集合。 */
participants(intent: OperationIntent): readonly Id[];
/** 同步编译一个意图;不在这里发送,payload/版本随后由 ledger.freeze 捕获。 */
compile(intent: OperationIntent, requestId: Id): CompiledRequest;
/** 将选中的连续候选缩为非空前缀,返回值必须是 1..intents.length 的整数。 */
batchLimit?: (intents: readonly OperationIntent[]) => number;
/** 可选抵消协调钩子:先实际标记 ledger,再返回已抵消的候选前缀 ID;调度器不自行构造证明。 */
elide?: (intents: readonly OperationIntent[]) => readonly Id[];
/** 多意图编译器仅在最终候选数 >1 时使用,单项仍走 compile。 */
compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest;
/** 使用保存的冻结请求发写端口;端口负责协议/权限与回执解释,不得重编 payload。 */
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
/** 按原请求身份查结果,端口解释回执;端口抛错时 scheduler 统一记为 unresolved/transport_unknown。 */
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
/** 恢复时确认冻结格式是否有解释器;省略钩子只默认支持 protocolVersion=1。 */
supportsRequest?: (request: FrozenRequest) => boolean;
/** 同步协调回执/base/working 与 ledger 的宿主边界;不得返回异步准备结果。 */
complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void;
/** 报告存储/协调/编译错误,scheduler 隔离此回调抛错,不能用它改写请求结果。 */
onError(error: unknown): void;
clock?: Clock;
concurrency?: number;
/** 按 submit attempt 提供有限退避毫秒;正常值取至少 1ms,端口 retryAfterMs 可覆盖此次值。 */
retryDelay?: (attempt: number) => number;
};
/** Queue/heap/claims are indexes only. Every authoritative transition lives in the ledger. */
/** 创建暂停态 scheduler;start 先恢复完整 claim 集,再允许执行任何新工作。 */
export function createScheduler(options: SchedulerOptions) {
const clock = options.clock ?? browserClock, concurrency = options.concurrency ?? 2;
if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw fault('invalid_concurrency');
// 准入门禁等待者不进入忙轮询;加载/授权等条件变化时由 refreshAdmission 显式唤醒。
const admissionWaiters=new Set<Id>();
// FIFO 保存待处理逻辑 ID,queued 防重复;claims 是 canvas→request,多个参与者一次认领。
const ready = createDeque<Id>(), queued = new Set<Id>(), claims = new Map<Id, Id>();
// waiters 按被占 canvas 等待,dependents 按逻辑依赖等待,ordered 保留全局序列以挑连续批。
const waiters = new Map<Id, Set<Id>>(), dependents = new Map<Id, Set<Id>>(), indexed = new Set<Id>();
// 最早 dueAt 优先;同到期按入堆 sequence 稳定排序,token 让旧 timer/旧重试条目惰性失效。
const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence);
// active 跟踪整条异步流水线(包含发送前/后的持久化等待),不是仅统计正在进行的 HTTP 请求。
// 句柄/AbortController 属于调度器,任务完成事实仍必须回到 ledger。
const tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void>; kind: FrozenRequest['kind'] }>();
// 多个恢复/关闭流程可独立 hold;只有最后一个 token 释放才继续准入,已启动工作仍正常收尾。
const holds = new Set<symbol>();
const ordered: { id: Id; sequence: number }[] = [];
let scheduledPump: Promise<void> | undefined;
// 上传 lane 单并发等容量限制不能让到期请求反复立即入堆,先 deferred,active 完成再唤醒。
const laneWaiters = new Map<NonNullable<FrozenRequest['kind']>, Set<Id>>(), deferredRetries = new Map<Id, { id: Id; dueAt: number; sequence: number; token: number }>();
let timer: unknown, pumpTimer: unknown, enqueueSequence = 0, stopped = true, closed = false, pumping = false;
/** 默认指数退避封顶 30s,最少 1ms;端口可提供 retryAfterMs 覆盖此次等待。 */
const delay = (attempt: number) => Math.max(1, options.retryDelay?.(attempt) ?? Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)));
/** 隔离错误报告回调,日志/宿主提示失败不能改变已知回执状态。 */
const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } };
/** 旧冻结协议必须有解释器才重试;缺钩子时仅默认支持协议 1。 */
const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1;
/** 总 active 上限之外,asset.upload 至多占一个 lane;其它类别可用剩余流水线执行位。 */
const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload'));
/** 先读 ledger 检查 pending/依赖/门禁,再用 queued 去重入 FIFO;不是通知一次就盲发一次。 */
function enqueue(id: Id) {
if (queued.has(id)) return;
const record = options.getLedger().operations.get(id);
if (!record || record.phase !== 'pending' || record.blockers.length || record.intent.dependencies.some(dependency => options.getLedger().operations.get(dependency)?.phase !== 'resolved')) {admissionWaiters.delete(id);return;}
if(options.admit&&!options.admit(record.intent)){admissionWaiters.add(id);return;}
admissionWaiters.delete(id);queued.add(id); ready.push(id);
}
/** 新逻辑 ID 仅索引一次:二分插入 sequence 表、登记反向依赖,再尝试入队。 */
function index(id: Id) {
const record = options.getLedger().operations.get(id);
if (!record) return;
if (!indexed.has(id)) {
indexed.add(id);
let lo = 0, hi = ordered.length;
while (lo < hi) { const mid = (lo + hi) >>> 1; if (ordered[mid].sequence < record.intent.sequence) lo = mid + 1; else hi = mid; }
ordered.splice(lo, 0, { id, sequence: record.intent.sequence });
for (const dependency of record.intent.dependencies) {
const set = dependents.get(dependency) ?? new Set<Id>(); set.add(id); dependents.set(dependency, set);
}
}
enqueue(id);
}
/** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */
/** 只选连续未冻结 document.change 前缀,最多 64 意图;追加候选时不让累计 changes 超过 10000。
* 首意图本身超过 10000 时仍可单独返回,不把它拆成字段请求;遇任务屏障/占用即停。
* 仅合并有相交参与者的 lane;独立 canvas 保留各自失败/重试边界。 */
function batch(first: OperationIntent, canvases: Set<Id>): readonly OperationIntent[] {
const selected = [first], ids = new Set([first.id]);
if ((!options.compileBatch && !options.elide) || first.operationType !== 'document.change' || first.operationSchemaVersion !== 1) return selected;
let lo = 0, hi = ordered.length, changeCount = first.changes.length;
while (lo < hi) { const mid = (lo + hi) >>> 1; if (ordered[mid].sequence <= first.sequence) lo = mid + 1; else hi = mid; }
const ledger = options.getLedger();
for (let i = lo; i < ordered.length && selected.length < 64; i++) {
const record = ledger.operations.get(ordered[i].id);
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || record.intent.operationType !== 'document.change' || record.intent.operationSchemaVersion !== 1) break;
if (record.intent.dependencies.some(id => !ids.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break;
if(options.admit&&!options.admit(record.intent))break;
const scope = options.participants(record.intent);
// Independent canvas lanes keep independent failure and retry boundaries.
if (!scope.length || !scope.some(id => canvases.has(id)) || scope.some(id => claims.has(id)) || changeCount + record.intent.changes.length > 10_000) break;
selected.push(record.intent); ids.add(record.intent.id); changeCount += record.intent.changes.length;
for (const id of scope) canvases.add(id);
}
return selected;
}
/** 支持合批时延到微任务,让同轮连续编辑有机会聚合;其它情况直接 pump。 */
function requestPump() {
if (!options.compileBatch && !options.elide) { pump(); return; }
if (!scheduledPump) scheduledPump = Promise.resolve().then(() => { scheduledPump = undefined; pump(); });
}
/** 先检查全部 canvas 无冲突,再统一设置 claims,避免持有一部分后等待另一部分。 */
function own(request: FrozenRequest) {
for (const canvas of request.canvasIds) {
const prior = claims.get(canvas);
if (prior && prior !== request.id) throw fault('recovery_claim_conflict', [canvas]);
}
for (const canvas of request.canvasIds) claims.set(canvas, request.id);
}
/** 释放仍属于原 request 的 claims,并唤醒对应 canvas 等待者及逻辑依赖者。 */
function release(request: FrozenRequest) {
for (const canvas of request.canvasIds) if (claims.get(canvas) === request.id) {
claims.delete(canvas);
const waiting = waiters.get(canvas); waiters.delete(canvas);
for (const id of waiting ?? []) enqueue(id);
}
for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
}
/** 可选 mutate 写回 retry 元数据,再用新 token 入 heap;persist=false 仅表示不重复写账本。
* 此处没有 await 存储屏障,真正可靠落盘仍由 launch/resume 的 options.persist 完成。 */
function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) {
if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action));
const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token);
retry.push({ id, dueAt, sequence: ++enqueueSequence, token }); armTimer();
}
/** 忽略已被新计划替代或已解决请求的旧 heap 条目。 */
function validRetry(item: { id: Id; token: number }) {
return tokens.get(item.id) === item.token && options.getLedger().requests.get(item.id)?.phase !== 'resolved';
}
/** 仅为最早有效重试设一个 timer;暂停、hold 或容量满时不安排无效唤醒。 */
function armTimer() {
if (timer !== undefined) { clock.clearTimer(timer); timer = undefined; }
while (retry.peek() && !validRetry(retry.peek()!)) retry.pop();
if (stopped || holds.size || active.size >= concurrency || !retry.peek()) return;
timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now()));
}
/** 安装 active 句柄后在微任务中执行:submit 先标 dispatch;query 保留现有发送风险,再共同等待持久化。
* 调端口→同步 complete→再次持久化;hold 不撤回已启动流水线,stop 则让晚到结果不再采纳。 */
function launch(id: Id, action: 'submit' | 'query') {
if (active.has(id) || stopped || holds.size) return;
const controller = new AbortController();
// Start in a microtask so the handle exists before any synchronous adapter error.
const promise = Promise.resolve().then(async () => {
try {
if (stopped) return;
if (action === 'submit') options.mutate(ledger => ledger.markDispatch(id));
// This barrier includes the exact body/key and the conservative dispatch marker.
// 请求身份与保守发送标记在这里可靠存储;失败就不接触外部端口,便于重开查询原请求。
await options.persist();
if (stopped) return;
const request = options.getLedger().requests.get(id)!;
let status: OperationStatus;
try { status = await (action === 'submit' ? options.submit(request, controller.signal) : options.query(request, controller.signal)); }
catch { status = { outcome: 'unresolved', error: 'transport_unknown' }; }
if (stopped) return; // A stale identity/runtime cannot accept a late callback.
// complete 可接受执行证据但阻塞后续协调;不能把网络 2xx 本身等同于本地基线已完成。
const completion = options.complete(request, status);
const current = options.getLedger().requests.get(id)!;
if (current.phase === 'resolved') { tokens.delete(id); await options.persist(); if (!stopped) release(current); }
else if (completion?.blocked || status.blocked) { await options.persist(); }
else {
// A missing receipt is unknown, so the only write retry reuses this frozen request.
// 首次 submit 未知先 query;query 仍未知才原样重试 submit;accepted 一直查,不换请求身份。
// accepted 的查询/submit 都继续 query;其它 submit 结果先 query,非 accepted 的 query 结果才允许同身份 submit。
const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit';
schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction);
await options.persist();
}
} catch (error) { report(error); } // Storage/reconciliation failure keeps its claims held.
}).finally(() => {
// 真实句柄结束后归还容量,重入 deferred retries/可用 lane,再 pump 独立工作。
active.delete(id);
if (!stopped) {
for (const item of deferredRetries.values()) retry.push(item);
deferredRetries.clear();
for (const [kind, waiting] of laneWaiters) if (canRun(kind)) { for (const operationId of waiting) enqueue(operationId); laneWaiters.delete(kind); }
pump();
}
});
active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind });
}
/** 防同步重入地处理到期请求,再从本轮 ready 前缀最多取 64 项;retry 循环没有同样的 64 项上限。
* 等待者移到索引,ready 尚有余量且有容量时安排下一轮 timer,不代表总队列大小有上限。 */
function pump() {
if (stopped || holds.size || pumping) return;
pumping = true;
try {
// 先处理到期恢复请求;上传 lane 被占时暂存,避免它阻塞独立文档请求。
while (active.size < concurrency && retry.peek() && retry.peek()!.dueAt <= clock.now()) {
const item = retry.pop()!;
if (!validRetry(item)) continue;
const request = options.getLedger().requests.get(item.id);
if (request && !active.has(item.id)) {
if (!canRun(request.kind)) { deferredRetries.set(item.id, item); continue; }
launch(item.id, request.retryAction ?? 'query');
}
}
let budget = Math.min(ready.size, 64);
while (active.size < concurrency && budget-- > 0) {
const id = ready.pop(); if (!id) break;
queued.delete(id);
const record = options.getLedger().operations.get(id);
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || record.intent.dependencies.some(dependency => options.getLedger().operations.get(dependency)?.phase !== 'resolved')) continue;
try {
if(options.admit&&!options.admit(record.intent)){admissionWaiters.add(id);continue;}
if (!canRun(record.intent.operationType)) {
const set = laneWaiters.get(record.intent.operationType) ?? new Set<Id>(); set.add(id); laneWaiters.set(record.intent.operationType, set); continue;
}
const canvases = [...new Set(options.participants(record.intent))].sort();
if (!canvases.length && record.intent.operationType !== 'asset.upload' && record.intent.operationType !== 'run.cancel') throw fault('missing_participants');
// 同时获取全部参与者,无“先锁 A 再等 B”;占用冲突时只登记等待,不持有局部 claim。
const occupied = canvases.filter(canvas => claims.has(canvas));
if (occupied.length) {
for (const canvas of occupied) { const set = waiters.get(canvas) ?? new Set<Id>(); set.add(id); waiters.set(canvas, set); }
continue;
}
const scope = new Set(canvases), selected = batch(record.intent, scope);
const limit = options.batchLimit?.(selected) ?? selected.length;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > selected.length) throw fault('invalid_batch_limit');
const candidates = selected.slice(0, limit);
// 可证明无外部效果的闭合前缀可本地抵消;必须核对实际 ledger 已标 elided,不能只信返回 ID。
const elided = options.elide?.(candidates) ?? [];
if (elided.length) {
if (elided.some((id, index) => id !== candidates[index]?.id || options.getLedger().operations.get(id)?.resolution !== 'elided')) throw fault('invalid_elision');
for (const id of elided) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
continue;
}
const intents = options.compileBatch ? candidates : [record.intent];
const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort();
// 编译参与者必须与实际 intents 推导的集合一致,随后 freeze/own/launch;没有 compileBatch 时只编译首意图。
// 异常走原逻辑 ID 的 block;若已 frozen,ledger.block 不会把它退回 pending,冻结身份仍保留。
const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId);
if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch');
options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) }));
own(options.getLedger().requests.get(requestId)!); launch(requestId, 'submit');
} catch (error) { options.mutate(ledger => ledger.block(id, [(error as Error).message || 'compile_failed'])); report(error); }
}
} catch(error) {
// A retired identity can reject both the attempt and its blocked-state write.
// Report the failure without rejecting an unobserved scheduled pump promise.
report(error);
} finally {
pumping = false; armTimer();
if (!stopped && !holds.size && ready.size && active.size < concurrency && pumpTimer === undefined) pumpTimer = clock.setTimer(() => { pumpTimer = undefined; pump(); }, 0);
}
}
return Object.freeze({
/** 首次启动(已运行时幂等):先恢复全部未解决 claims,之后才开始端口工作;claim 冲突会在仍 stopped 时抛错。
* 解释器缺失保留占用并写 blocker;此步骤不做完整恢复 DTO 校验,也不从单个错误推断请求未执行。 */
start() {
if (closed) throw fault('scheduler_closed');
if (!stopped) return;
// Validate the entire recovered claim set before sending anything.
claims.clear();
for (const request of options.getLedger().requests.values()) if (request.phase !== 'resolved') own(request);
stopped = false;
for (const operation of [...options.getLedger().operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) index(operation.intent.id);
for (const request of options.getLedger().requests.values()) if (request.phase !== 'resolved') {
if (!supported(request)) { options.mutate(ledger => ledger.blockRequest(request.id, 'request_interpreter_unavailable')); continue; }
schedule(request.id, request.retryAt ?? clock.now(), request.retryAction ?? (request.phase === 'frozen' ? 'submit' : 'query'), false);
}
pump();
},
/** 门禁事实变化后显式重试准入,未满足者继续等待,不制造定时器忙轮询。 */
refreshAdmission(){for(const id of [...admissionWaiters])enqueue(id);if(ready.size)requestPump();},
/** 新逻辑操作通知推进派生索引;正在 queued 时不重复入队,indexed 让依赖只注册一次。
* 已出队的 pending 操作后续 notify 可再次尝试准入;通知本身不新增 ledger 意图。 */
notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); requestPump(); },
/** Acquire synchronously, then settle(): already admitted work completes with its original identity. */
/** 同步阻止新准入并清唤醒 timer;返回幂等释放函数,已准入异步工作不被撤销或换身份。 */
hold() {
if (closed) throw fault('scheduler_closed');
const token = Symbol(); holds.add(token);
if (timer !== undefined) clock.clearTimer(timer);
if (pumpTimer !== undefined) clock.clearTimer(pumpTimer);
timer = pumpTimer = undefined;
return () => {
if (!holds.delete(token) || holds.size || stopped) return;
pump();
};
},
/** Explicit retry after storage/auth/permission recovery; preserves frozen bodies. */
/** 存储/授权恢复后的显式重试:先补持久化,再释放已解决 claims 或继续查原请求。 */
async resume() {
if (stopped) return;
// A receipt may be in memory after its durable write failed. Keep claims until retry succeeds.
await options.persist();
if (stopped) return;
for (const request of options.getLedger().requests.values()) {
if (active.has(request.id)) continue;
if (request.phase === 'resolved') release(request);
else if (supported(request)) schedule(request.id, request.retryAt ?? clock.now(), request.retryAction ?? 'query', false);
}
for (const id of indexed) enqueue(id);
pump();
},
/** 返回当前占用表的只读副本,调用者不能手改 claim 绕过未知请求。 */
getClaims: () => readMap(claims),
/** 等待当前微任务/pump/active 收尾,不主动等待未来 retry timer,因此不是“全部已同步”保证。
* 端口若长期不完成(包括忽略 abort),active Promise 仍可让 settle 一直等待。 */
async settle() {
do {
if (scheduledPump) await scheduledPump;
if (active.size) await Promise.all([...active.values()].map(item => item.promise));
if (!stopped && !holds.size && pumpTimer !== undefined) { clock.clearTimer(pumpTimer); pumpTimer = undefined; pump(); }
} while (scheduledPump || active.size || (!stopped && !holds.size && pumpTimer !== undefined));
},
/** 永久关闭本 scheduler、取消 timer 并请求 abort active;晚到响应因 stopped 不再采纳,不能再 start。
* abort 不证明外部未执行,也不清 ledger/claims/派生容器或替 engine 关闭存储;活动句柄在 finally 收尾。 */
stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); },
});
}