mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 09:02:15 +00:00
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:
committed by
Peter Steinberger
parent
a36bb119be
commit
e777a2b230
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user