fix(process ): migrate legacy command-queue singleton missing activeTaskWaiters

After a SIGUSR1 in-process restart following an npm upgrade from v2026.4.2
to v2026.4.5, the globalThis singleton created by the old code version
lacks the activeTaskWaiters field added in v2026.4.5.  resolveGlobalSingleton
returns the stale object as-is, causing notifyActiveTaskWaiters() to call
Array.from(undefined) and crash the gateway in a loop.

Add a schema migration step in getQueueState() that patches the missing
field on legacy singleton objects.  Add a regression test that plants a
v2026.4.2-shaped state object and verifies resetAllLanes() and
waitForActiveTasks() succeed without throwing.

Fixes #61905
This commit is contained in:
openperf
2026-04-06 22:24:16 +08:00
committed by Peter Steinberger
parent a36bb119be
commit e777a2b230
2 changed files with 47 additions and 1 deletions

View File

@@ -378,6 +378,42 @@ describe("command queue", () => {
await expect(enqueueCommand(async () => "ok")).resolves.toBe("ok");
});
it("migrates legacy queue state missing activeTaskWaiters without crashing", async () => {
// Simulate a SIGUSR1 in-process restart where the globalThis singleton was
// created by an older code version (e.g. v2026.4.2) that did not include
// the `activeTaskWaiters` field. The schema migration in getQueueState()
// must patch the missing field so resetAllLanes() and
// notifyActiveTaskWaiters() do not throw.
const key = Symbol.for("openclaw.commandQueueState");
const globalStore = globalThis as Record<PropertyKey, unknown>;
const original = globalStore[key];
try {
// Plant a legacy-shaped state object (no activeTaskWaiters).
globalStore[key] = {
gatewayDraining: false,
lanes: new Map(),
nextTaskId: 1,
};
// resetAllLanes calls notifyActiveTaskWaiters → Array.from(state.activeTaskWaiters).
// Without the migration this would throw:
// TypeError: undefined is not iterable
expect(() => resetAllLanes()).not.toThrow();
// waitForActiveTasks also accesses activeTaskWaiters.
await expect(waitForActiveTasks(0)).resolves.toEqual({ drained: true });
} finally {
// Restore original state so subsequent tests are not affected.
if (original !== undefined) {
globalStore[key] = original;
} else {
delete globalStore[key];
}
resetCommandQueueStateForTest();
}
});
it("shares lane state across distinct module instances", async () => {
const commandQueueA = await importFreshModule<typeof import("./command-queue.js")>(
import.meta.url,

View File

@@ -64,12 +64,22 @@ function isExpectedNonErrorLaneFailure(err: unknown): boolean {
const COMMAND_QUEUE_STATE_KEY = Symbol.for("openclaw.commandQueueState");
function getQueueState() {
return resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({
const state = resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({
gatewayDraining: false,
lanes: new Map<string, LaneState>(),
activeTaskWaiters: new Set<ActiveTaskWaiter>(),
nextTaskId: 1,
}));
// Schema migration: the singleton may have been created by an older code
// version (e.g. v2026.4.2) that did not include `activeTaskWaiters`. After
// a SIGUSR1 in-process restart the new code inherits the stale object via
// `resolveGlobalSingleton` because the Symbol key already exists on
// globalThis. Patch the missing field so all downstream consumers see a
// valid Set instead of `undefined`.
if (!state.activeTaskWaiters) {
state.activeTaskWaiters = new Set<ActiveTaskWaiter>();
}
return state;
}
function normalizeLane(lane: string): string {