openclaw - ✅(Solved) Fix gateway/nodes: maybeWakeNodeWithApns sets nodeWakeById before registration check, leaking entries for unregistered nodeIds [4 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#68847Fetched 2026-04-19 15:06:50
View on GitHub
Comments
0
Participants
1
Timeline
7
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×4referenced ×3

Fix Action

Fixed

PR fix notes

PR #68848: fix(gateway): clear nodeWakeById on no-registration early-return

Description (problem / solution / changelog)

Summary

  • Problem: `maybeWakeNodeWithApns` (nodes.ts:308-416) sets `nodeWakeById` speculatively at L312-313 (for in-flight coalescing) before the L333 `loadApnsRegistration` check. On the no-registration early-return path the entry is never removed, so an operator-driven RPC against an unregistered / re-paired / typo nodeId leaks a permanent entry.
  • Why it matters: the sole cleanup (`clearNodeWakeState` at L531) is wired only from `ws-connection.ts:327` (WS close + role=node + registered). Unregistered nodeIds never complete that cycle, so their `{ lastWakeAtMs: 0 }` entries persist in the module-scope Map for the lifetime of the gateway.
  • What changed: delete the entry before returning the no-registration result. Mirrors the house pattern (helper cleanup at the return site). Adds a narrow read-only `__testing` seam mirrored on `agent-wait-dedupe.ts:223` and `agents.ts:78`.
  • What did NOT change: the in-flight coalescing invariant is preserved. Concurrent callers parked on `state.inFlight` await the same promise; the delete only drops throttle bookkeeping that is meaningless for a nodeId with no registration.

Change Type

  • Bug fix

Scope

  • gateway

Linked Issue

Closes #68847

Root Cause

Missing guardrail at the no-registration return site. The function commits to a Map entry for coalescing before it knows whether the registration exists, and only the WS-close path cleans up — which never fires for unregistered nodeIds.

Scope delineation vs PR #63709

PR #63709 (merged 2026-04-09) introduced `clearNodeWakeState` for the WS-close lifecycle (registered nodes). This PR addresses a different leak path — unregistered-nodeId early-return — that WS-close cleanup does not cover. The two changes are complementary:

scopeintroducedpath
PR #637092026-04-09WS close → `clearNodeWakeState` → clears registered nodes
this PR2026-04-19no-registration early-return → clears unregistered nodes

Regression Test Plan

Added `src/gateway/server-methods/nodes.wake-leak.test.ts`:

  • Mocks only the external `push-apns` module. The null-registration branch is the same branch that `nodes.invoke-wake.test.ts:279/491/572/640` already exercises, so this is CAL-003 compliant (no synthetic branch strange-forcing).
  • Calls `maybeWakeNodeWithApns` 50 times with distinct unregistered nodeIds, asserts `__testing.getNodeWakeByIdSize() === 0` afterwards.
  • Pre-fix: size === 50 (leak). Post-fix: size === 0.

Security Impact

None. No new permissions, secrets, network calls, or data scope change.

Repro + Verification

Environment: Node 22, macOS, `pnpm 10.33.0`.

Steps:

  1. `pnpm test src/gateway/server-methods/nodes.wake-leak.test.ts`

Expected (post-fix): 2 passed. Actual (pre-fix): 2 failed (`getNodeWakeByIdSize()` returns 50 / 1 instead of 0).

Evidence

  • Pre-fix (stashed): 2 failed.
  • Post-fix: 2 passed.
  • Full `src/gateway/server-methods/` suite: 36 files / 433 tests passed.
  • `pnpm check` + `pnpm build` clean.

Human Verification

  • Confirmed the sole cleanup call site (`ws-connection.ts:327`) only fires for `role=node` + registered nodeIds.
  • Confirmed all three `maybeWakeNodeWithApns` production callers (nodes.ts:920, 943, 1050) can receive operator-supplied nodeIds that may be unregistered.
  • Confirmed the `__testing` seam is read-only + mirrored on existing house-style exports (`agent-wait-dedupe.ts:223`, `agents.ts:78`).

Review Conversations

Greptile + Codex reviews will run on the PR; will respond to any flagged items.

Compatibility / Migration

None. Internal behavioral fix; no public API or config surface touched.

Risks and Mitigations

  • Risk: deleting the entry causes a concurrent wake caller to miss throttle bookkeeping. Mitigation: throttle is meaningless for a nodeId with no registration (every future call hits the same no-registration branch cheaply). Concurrent callers still share `state.inFlight` because they captured it before the delete.
  • Risk: `__testing` seam widens the public API surface. Mitigation: the seam is read-only (size getter + has check + reset) and does not expose the underlying Map. Mirrors the two existing house-style `__testing` exports in `src/gateway/`.

AI-assisted (fully tested). Generated via openclaw-audit pipeline (gatekeeper approve + post-harness cross-review 5/5 real-problem-real-fix + pre-pr cross-review 3/3 real-problem-real-fix).

Changed files

  • src/gateway/server-methods/nodes.ts (modified, +23/-0)
  • src/gateway/server-methods/nodes.wake-leak.test.ts (added, +77/-0)

PR #68902: fix(gateway): clean up nodeWakeById entry on no-registration early return

Description (problem / solution / changelog)

Summary

maybeWakeNodeWithApns sets nodeWakeById.set(nodeId, state) on entry, but the no-registration early return path does not clean up the entry. This causes stale entries to accumulate for unregistered nodeIds, leaking memory over time.

Fix

Add nodeWakeById.delete(nodeId) before the early return when loadApnsRegistration(nodeId) returns null.

Testing

  • Unregistered node wake attempts no longer leave stale entries in nodeWakeById
  • Registered node wake attempts continue to work normally
  • clearNodeWakeState still cleans up on WS close as before

Closes #68847

Changed files


PR #68912: fix(gateway): clean up nodeWakeById entry on no-registration early return

Description (problem / solution / changelog)

maybeWakeNodeWithApns sets nodeWakeById.set(nodeId, state) on entry, but the no-registration early return path does not clean up the entry, causing stale entries to accumulate for unregistered nodeIds. Add nodeWakeById.delete(nodeId) before the early return.

Closes #68847

Changed files

  • src/gateway/server-methods/nodes.ts (modified, +1/-0)

PR #68973: fix(gateway): clean up nodeWakeById entry on no-registration return

Description (problem / solution / changelog)

Clean up stale nodeWakeById entries when loadApnsRegistration returns null.

Closes #68847

Changed files

RAW_BUFFERClick to expand / collapse

요약

gateway/nodes: maybeWakeNodeWithApns 가 registration 체크 전에 nodeWakeById 에 엔트리를 set 하여 미등록 nodeId 에 대한 누수

공통 패턴

단일 FIND 기반 single CAND. src/gateway/server-methods/nodes.ts:312-313 에서 maybeWakeNodeWithApns 진입 즉시 nodeWakeById.set(nodeId, state) 가 수행된다. 이후 L333 loadApnsRegistration(nodeId) 이 null 을 반환하면 L334-336 에서 path: "no-registration" 으로 조기 반환되지만, 이 경로에서 엔트리를 정리하는 cleanup 은 부재하다.

관련 FIND

  • FIND-gateway-memory-002: 미등록/재페어링된/오탈자 nodeId 로 wake 호출이 들어오면 nodeWakeById (및 연관 nodeWakeNudgeById) 에 엔트리가 누적. 유일 삭제 경로인 clearNodeWakeState 는 WS close 이벤트 + role=node + registered 삼중 조건에만 발사되므로 미등록 nodeId 에 대해서는 fire 안 함.

근거 위치

  • 선언: src/gateway/server-methods/nodes.ts:72 (nodeWakeById), :73 (nodeWakeNudgeById)
  • set 경로 (registration 전): src/gateway/server-methods/nodes.ts:312-313
  • no-registration 반환: src/gateway/server-methods/nodes.ts:333-336
  • 유일 delete 경로: src/gateway/server-methods/nodes.ts:525-528 (clearNodeWakeState)
  • 호출자: src/gateway/server/ws-connection.ts:327 (WS close, role=node + registered)

영향

  • impact_hypothesis: memory-growth (slow leak, 엔트리 크기 ~100B)
  • 스크립팅/자동화 환경에서 오래된 nodeId 재사용 시 누적
  • clearStaleApnsRegistrationIfNeeded (L374) 는 APNs registration 만 지우고 nodeWakeById 는 안 건드림 → "좀비 엔트리" 가능
  • P3 — 빈도는 auth-gated, 엔트리 크기 작음

대응 방향 (제안만)

no-registration 분기에 conditional nodeWakeById.delete(nodeId) (단, state.inFlight 참조 공유 invariant 를 깨지 않도록 주의). 또는 clearStaleApnsRegistrationIfNeeded 에서 함께 정리. 구체는 SOL 단계.

반증 메모

  • operator 가 arbitrary nodeId 를 보낼 수 있는 RBAC 범위 미확인 → allowlist 있으면 빈도 추가 감소.
  • state.inFlight promise 가 resolve 후 nullify 되는지 확인 안 함 (drift risk).

관련 Finding 상세

1. nodeWakeById set 이 APNs registration 검증 전에 실행되어 미등록 nodeId 누수

  • 파일: src/gateway/server-methods/nodes.ts:308-336
  • 증상 유형: memory-leak
  • 예상 영향: memory-growth — 정량 상한 (관측치 없음):
  • 누적 속도 = 미등록 nodeId 로 들어오는 wake 호출 수. 정상 사용에서는 매우 낮지만, operator tooling 이 오래된 nodeId 를 재사용하거나 스크립팅/자동화에서 재페어링 이후 stale id 를 재활용하면 N 일에 N 엔트리.
  • 엔트리 크기: NodeWakeState = { lastWakeAtMs: number; inFlight?: Promise<NodeWakeAttempt> } — 40~100 bytes. nodeWakeNudgeById 엔트리는 number 하나 (더 작음).
  • 가장 현실적 리스크: 장기 구동 + paired device churn 이 있는 설치에서 수백~수천 엔트리. OOM 까지는 아니고 slow heap growth.
<details><summary>증거 / 메커니즘 / 근본 원인</summary>

maybeWakeNodeWithApns 가 registration 체크 이전에 nodeWakeById 에 엔트리를 set 하여 미등록 nodeId 에 대한 누수 가능

문제

nodeWakeById / nodeWakeNudgeById 는 APNs wake 호출에 대한 throttle/dedupe state 를 nodeId 로 보관한다. maybeWakeNodeWithApns 는 진입 즉시 맵에 엔트리를 set 한 뒤 (L312-313), loadApnsRegistration 결과를 보고 미등록이면 path: "no-registration" 으로 조기 반환한다. 삭제는 clearNodeWakeState — WS close 이벤트 중 해당 nodeId 가 registered 인 경우에만 실행. 미등록/재페어링/오탈자 nodeId 로 wake 가 호출되면 엔트리가 정리되지 않는다.

발현 메커니즘

  1. 인증된 operator 가 node.pending.enqueue 또는 node.invoke RPC 호출 (nodeId 는 operator 인풋).
  2. maybeWakeNodeWithApns(nodeId) 진입 → L312-313 nodeWakeById.set(nodeId, state).
  3. L333 await loadApnsRegistration(nodeId) 가 null 반환 (APNs 미등록 / 이전에 clear 됨).
  4. L334-336 에서 path: "no-registration" 으로 조기 반환. nodeWakeById 엔트리는 남음.
  5. 해당 nodeId 의 WS 연결이 없으므로 clearNodeWakeState 가 절대 호출되지 않음.
  6. 동일 또는 다른 미등록 nodeId 로 호출이 반복되면 엔트리 누적. nodeWakeNudgeByIdsendApnsAlert 성공 시에만 set (L483) — 일반 경로는 주로 nodeWakeById 에 누수.

근본 원인 분석

설계상 L312-313 의 unconditional set 은 state.inFlight 공유를 통해 동일 nodeId 에 대한 concurrent wake 호출을 하나로 통합하기 위함이다. 그러나 no-registration 경로에서 엔트리를 지우는 cleanup 이 추가되지 않았다. cleanup 은 WS close 경로에만 존재 — 전제는 "wake 가 호출되는 nodeId 는 언젠가 WS 연결을 경험한다" 지만, maybeWakeNodeWithApnsno-registration 반환을 정상 결과로 둔다 (L334). 따라서 전제가 깨진다.

node.pending.enqueue validator 는 nodeId 를 operator 인풋으로 받으며 현재 paired 상태인지 교차 검증하지 않는다 (nodes-pending.ts:60-88). operator tooling 이 재페어링 후 이전 nodeId 를 재사용하거나 스크립팅 오류로 잘못된 id 를 보내면 누수 경로가 활성화된다.

영향

  • 영향 유형: memory-growth (slow leak, 엔트리 크기 작음).
  • 관측: nodeWakeById / nodeWakeNudgeById size 가 미등록 nodeId 호출 횟수에 비례하여 증가.
  • 재현: 임의 nodeId 로 node.pending.enqueue 를 반복 호출 → nodeWakeById.has(nodeId) 영구 true.
  • severity P3: 엔트리 크기 ~100B 로 작고 누적 속도가 auth-gated 한 호출 빈도에 의존. 단, clearStaleApnsRegistrationIfNeeded (L374) 로 registration 이 지워진 후에도 nodeWakeById 엔트리는 살아남아 "좀비" 가 될 수 있음.

반증 탐색

카테고리 1 (이미 cleanup 있는지): R-3 Grep 으로 nodeWakeById.(delete|clear) / clearNodeWakeState 탐색. delete 경로는 clearNodeWakeState 하나, 호출자는 ws-connection.ts:327 하나. WS close 이벤트 + role=node + registered nodeId 삼중 조건. 미등록 nodeId 에 대해서는 fire 안 함.

카테고리 2 (외부 경계 장치): clearStaleApnsRegistrationIfNeeded (L374) 는 APNs registration 자체를 정리하지만 nodeWakeById 엔트리는 건드리지 않음. 두 맵이 decoupled cleanup 이라 gap 존재. cron sweeper / maintenance interval 에서 정리하는 로직 없음 (R-3 Grep while.*nodeWakeById.size 0 hits).

카테고리 3 (호출 맥락): node.pending.enqueue 는 인증된 operator 인풋 기반. 빈도는 낮으나 스크립팅/자동화 환경에서 꾸준히 발생.

카테고리 4 (기존 테스트): nodes.invoke-wake.test.ts:332-351clearNodeWakeState 의 동작을 검증 — "WS close 기반 cleanup" 만 테스트. "미등록 nodeId 호출 이후 엔트리가 남는다" 는 누수 시나리오는 커버되지 않음.

카테고리 5 (주석/의도): L312 부근 주석 없음. no-registration 경로에서 cleanup 생략이 의도적이라는 표시 없음.

Primary-path inversion: "누적 안 된다" 가 참이려면 모든 wake 호출이 최종적으로 WS 연결 cycle 을 거친 nodeId 에 대해서만 이뤄져야 한다. 그러나 설계는 no-registration 을 정상 반환 경로로 허용 — 성립 안 함.

Self-check

내가 확실한 근거

  • src/gateway/server-methods/nodes.ts:312-336, 483, 525-528 Read 로 확인.
  • src/gateway/server/ws-connection.ts:316-328 에서 clearNodeWakeState 호출처 단일 확인.
  • R-3 Grep 으로 cap/TTL/prune 경로 0 매치 확인.

내가 한 가정

  • operator tooling 이 미등록 nodeId 로 wake 를 호출할 빈도 — 추정. 실제 관측치 없음.
  • 엔트리 크기가 약 100B — NodeWakeState 구조 기반 추정.

확인 안 한 것 중 영향 가능성

  • operator 가 실제로 arbitrary nodeId 를 보낼 수 있는지 (RBAC 범위) 확인 안 함. 만일 상위 auth 레이어에서 paired nodeId allowlist 강제하면 이 FIND 의 빈도는 더 낮아진다.
  • nodeWakeNudgeById 의 set 경로 (L483) 가 sendApnsAlert 성공 시에만 발동 — 미등록 nodeId 는 no-registration 으로 조기 반환하므로 nudge 맵에는 덜 영향. 주 누수는 nodeWakeById.
  • state.inFlight 가 promise resolve 이후 finally 등으로 nullify 되는지 직접 확인 안 함 — 만약 promise ref 가 state 에 계속 잡혀 있으면 엔트리당 메모리가 증가한다 (추가 drift risk).
</details>

<sub>이 이슈는 openclaw-audit 로컬 신뢰성 감사 파이프라인에서 생성됨. 재현 테스트와 수정은 별도 PR 에 포함됩니다.</sub>

<!-- openclaw-audit: cand=CAND-015 fingerprints=e55ce9192371 at=2026-04-19T06:36:28+00:00 -->

extent analysis

TL;DR

The most likely fix is to add a conditional nodeWakeById.delete(nodeId) in the no-registration branch to prevent memory leaks.

Guidance

  • Identify the no-registration branch in maybeWakeNodeWithApns and add a conditional nodeWakeById.delete(nodeId) to clean up unused entries.
  • Verify that the state.inFlight promise is properly nullified after resolution to prevent additional memory drift.
  • Review the clearStaleApnsRegistrationIfNeeded function to ensure it does not introduce any gaps in cleanup.
  • Consider implementing a cron sweeper or maintenance interval to periodically clean up stale entries.

Example

// In maybeWakeNodeWithApns
if (await loadApnsRegistration(nodeId) === null) {
  // ...
  nodeWakeById.delete(nodeId); // Add this line to clean up unused entries
  return { path: "no-registration" };
}

Notes

  • The fix assumes that the nodeWakeById map is properly synchronized and that the delete operation is thread-safe.
  • The example code snippet is a minimal illustration and may require additional modifications to fit the specific use case.
  • Further testing and verification are necessary to ensure the fix does not introduce any regressions.

Recommendation

Apply the workaround by adding a conditional nodeWakeById.delete(nodeId) in the no-registration branch, as it is a targeted fix that addresses the specific memory leak issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING