a49dd83b14
clearCommandLane() was truncating the queue array without calling resolve/reject on pending entries, causing never-settling promises and memory leaks when upstream callers await enqueueCommandInLane(). Splice entries and reject each before clearing so callers can handle the cancellation gracefully.
236 lines
6.3 KiB
TypeScript
236 lines
6.3 KiB
TypeScript
import { diagnosticLogger as diag, logLaneDequeue, logLaneEnqueue } from "../logging/diagnostic.js";
|
|
import { CommandLane } from "./lanes.js";
|
|
|
|
// Minimal in-process queue to serialize command executions.
|
|
// Default lane ("main") preserves the existing behavior. Additional lanes allow
|
|
// low-risk parallelism (e.g. cron jobs) without interleaving stdin / logs for
|
|
// the main auto-reply workflow.
|
|
|
|
type QueueEntry = {
|
|
task: () => Promise<unknown>;
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason?: unknown) => void;
|
|
enqueuedAt: number;
|
|
warnAfterMs: number;
|
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
|
};
|
|
|
|
type LaneState = {
|
|
lane: string;
|
|
queue: QueueEntry[];
|
|
active: number;
|
|
activeTaskIds: Set<number>;
|
|
maxConcurrent: number;
|
|
draining: boolean;
|
|
};
|
|
|
|
const lanes = new Map<string, LaneState>();
|
|
let nextTaskId = 1;
|
|
|
|
function getLaneState(lane: string): LaneState {
|
|
const existing = lanes.get(lane);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const created: LaneState = {
|
|
lane,
|
|
queue: [],
|
|
active: 0,
|
|
activeTaskIds: new Set(),
|
|
maxConcurrent: 1,
|
|
draining: false,
|
|
};
|
|
lanes.set(lane, created);
|
|
return created;
|
|
}
|
|
|
|
function drainLane(lane: string) {
|
|
const state = getLaneState(lane);
|
|
if (state.draining) {
|
|
return;
|
|
}
|
|
state.draining = true;
|
|
|
|
const pump = () => {
|
|
while (state.active < state.maxConcurrent && state.queue.length > 0) {
|
|
const entry = state.queue.shift() as QueueEntry;
|
|
const waitedMs = Date.now() - entry.enqueuedAt;
|
|
if (waitedMs >= entry.warnAfterMs) {
|
|
entry.onWait?.(waitedMs, state.queue.length);
|
|
diag.warn(
|
|
`lane wait exceeded: lane=${lane} waitedMs=${waitedMs} queueAhead=${state.queue.length}`,
|
|
);
|
|
}
|
|
logLaneDequeue(lane, waitedMs, state.queue.length);
|
|
const taskId = nextTaskId++;
|
|
state.active += 1;
|
|
state.activeTaskIds.add(taskId);
|
|
void (async () => {
|
|
const startTime = Date.now();
|
|
try {
|
|
const result = await entry.task();
|
|
state.active -= 1;
|
|
state.activeTaskIds.delete(taskId);
|
|
diag.debug(
|
|
`lane task done: lane=${lane} durationMs=${Date.now() - startTime} active=${state.active} queued=${state.queue.length}`,
|
|
);
|
|
pump();
|
|
entry.resolve(result);
|
|
} catch (err) {
|
|
state.active -= 1;
|
|
state.activeTaskIds.delete(taskId);
|
|
const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-");
|
|
if (!isProbeLane) {
|
|
diag.error(
|
|
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
|
|
);
|
|
}
|
|
pump();
|
|
entry.reject(err);
|
|
}
|
|
})();
|
|
}
|
|
state.draining = false;
|
|
};
|
|
|
|
pump();
|
|
}
|
|
|
|
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
|
|
const cleaned = lane.trim() || CommandLane.Main;
|
|
const state = getLaneState(cleaned);
|
|
state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent));
|
|
drainLane(cleaned);
|
|
}
|
|
|
|
export function enqueueCommandInLane<T>(
|
|
lane: string,
|
|
task: () => Promise<T>,
|
|
opts?: {
|
|
warnAfterMs?: number;
|
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
|
},
|
|
): Promise<T> {
|
|
const cleaned = lane.trim() || CommandLane.Main;
|
|
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
|
const state = getLaneState(cleaned);
|
|
return new Promise<T>((resolve, reject) => {
|
|
state.queue.push({
|
|
task: () => task(),
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
enqueuedAt: Date.now(),
|
|
warnAfterMs,
|
|
onWait: opts?.onWait,
|
|
});
|
|
logLaneEnqueue(cleaned, state.queue.length + state.active);
|
|
drainLane(cleaned);
|
|
});
|
|
}
|
|
|
|
export function enqueueCommand<T>(
|
|
task: () => Promise<T>,
|
|
opts?: {
|
|
warnAfterMs?: number;
|
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
|
},
|
|
): Promise<T> {
|
|
return enqueueCommandInLane(CommandLane.Main, task, opts);
|
|
}
|
|
|
|
export function getQueueSize(lane: string = CommandLane.Main) {
|
|
const resolved = lane.trim() || CommandLane.Main;
|
|
const state = lanes.get(resolved);
|
|
if (!state) {
|
|
return 0;
|
|
}
|
|
return state.queue.length + state.active;
|
|
}
|
|
|
|
export function getTotalQueueSize() {
|
|
let total = 0;
|
|
for (const s of lanes.values()) {
|
|
total += s.queue.length + s.active;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export function clearCommandLane(lane: string = CommandLane.Main) {
|
|
const cleaned = lane.trim() || CommandLane.Main;
|
|
const state = lanes.get(cleaned);
|
|
if (!state) {
|
|
return 0;
|
|
}
|
|
const removed = state.queue.length;
|
|
const pending = state.queue.splice(0);
|
|
for (const entry of pending) {
|
|
entry.reject(new Error("Command lane cleared"));
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Returns the total number of actively executing tasks across all lanes
|
|
* (excludes queued-but-not-started entries).
|
|
*/
|
|
export function getActiveTaskCount(): number {
|
|
let total = 0;
|
|
for (const s of lanes.values()) {
|
|
total += s.active;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Wait for all currently active tasks across all lanes to finish.
|
|
* Polls at a short interval; resolves when no tasks are active or
|
|
* when `timeoutMs` elapses (whichever comes first).
|
|
*
|
|
* New tasks enqueued after this call are ignored — only tasks that are
|
|
* already executing are waited on.
|
|
*/
|
|
export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolean }> {
|
|
// Keep shutdown/drain checks responsive without busy looping.
|
|
const POLL_INTERVAL_MS = 50;
|
|
const deadline = Date.now() + timeoutMs;
|
|
const activeAtStart = new Set<number>();
|
|
for (const state of lanes.values()) {
|
|
for (const taskId of state.activeTaskIds) {
|
|
activeAtStart.add(taskId);
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const check = () => {
|
|
if (activeAtStart.size === 0) {
|
|
resolve({ drained: true });
|
|
return;
|
|
}
|
|
|
|
let hasPending = false;
|
|
for (const state of lanes.values()) {
|
|
for (const taskId of state.activeTaskIds) {
|
|
if (activeAtStart.has(taskId)) {
|
|
hasPending = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasPending) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hasPending) {
|
|
resolve({ drained: true });
|
|
return;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
resolve({ drained: false });
|
|
return;
|
|
}
|
|
setTimeout(check, POLL_INTERVAL_MS);
|
|
};
|
|
check();
|
|
});
|
|
}
|