mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 22:21:33 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user