fix(gateway): prune empty node-pending-work state entries to prevent memory leak (#58179)

Merged via squash.

Prepared head SHA: 1efee3099f
Co-authored-by: gavyngong <267269824+gavyngong@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
gavyngong
2026-04-02 20:00:18 +08:00
committed by GitHub
parent 9823833383
commit 761cdc967d
3 changed files with 26 additions and 0 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson.
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. Thanks @vincentkoc.
- Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.
## 2026.4.1-beta.1

View File

@@ -64,4 +64,21 @@ describe("node pending work", () => {
expect(acked).toEqual({ revision: 0, removedItemIds: [] });
expect(getNodePendingWorkStateCountForTests()).toBe(0);
});
it("prunes the state entry once all explicit items are acknowledged", () => {
const { item } = enqueueNodePendingWork({ nodeId: "node-5", type: "status.request" });
expect(getNodePendingWorkStateCountForTests()).toBe(1);
acknowledgeNodePendingWork({ nodeId: "node-5", itemIds: [item.id] });
expect(getNodePendingWorkStateCountForTests()).toBe(0);
});
it("prunes the state entry when all items expire naturally via drain", () => {
enqueueNodePendingWork({ nodeId: "node-6", type: "location.request", expiresInMs: 5_000 });
expect(getNodePendingWorkStateCountForTests()).toBe(1);
// Drain well after the item has expired (Date.now() + 60s > enqueue time + 5s)
drainNodePendingWork("node-6", { nowMs: Date.now() + 60_000 });
expect(getNodePendingWorkStateCountForTests()).toBe(0);
});
});

View File

@@ -71,6 +71,12 @@ function pruneExpired(state: NodePendingWorkState, nowMs: number): boolean {
return changed;
}
function pruneStateIfEmpty(nodeId: string, state: NodePendingWorkState) {
if (state.itemsById.size === 0) {
stateByNodeId.delete(nodeId);
}
}
function sortedItems(state: NodePendingWorkState): NodePendingWorkItem[] {
return [...state.itemsById.values()].toSorted((a, b) => {
const priorityDelta = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority];
@@ -138,6 +144,7 @@ export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): D
const revision = state?.revision ?? 0;
if (state) {
pruneExpired(state, nowMs);
pruneStateIfEmpty(normalizedNodeId, state);
}
const maxItems = Math.min(MAX_ITEMS, Math.max(1, Math.trunc(opts.maxItems ?? DEFAULT_MAX_ITEMS)));
const explicitItems = state ? sortedItems(state) : [];
@@ -181,6 +188,7 @@ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: st
if (removedItemIds.length > 0) {
state.revision += 1;
}
pruneStateIfEmpty(nodeId, state);
return { revision: state.revision, removedItemIds };
}