Skip to content

fix(plugin): harden remembered-snapshot locking + activate registered flag - #171

Merged
ranxianglei merged 2 commits into
masterfrom
2026-08-18_plugin-lock-and-registered
Aug 21, 2026
Merged

ranxianglei merged 2 commits into
masterfrom
2026-08-18_plugin-lock-and-registered

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Review of the merged 里应外合 / cooperative-plugin feature (#161 + #163) found three defensive cleanups. No reproducible bug in the normal flow — these are lock-consistency / dead-code hardening.

Changes

1. src/mcp.ts — activate the dead registered flag
The registered flag was checked in initialize (if (conversationId && !registered)) but never set to true, so the guard was inert and a repeated initialize would re-issue the register POST. Now registered = true is set once the register request is issued. (The register endpoints are idempotent — identity re-sets the map, headless dedups by conversationId — so there was no live harm; the guard is now actually functional and self-documenting.)

2. src/plugin.ts handlePluginTool — read remembered under the session lock
remembered.get(session.id) and the derived messages/nudge were read before withSessionLock. A tool call racing a concurrent model request (which sits between recordPluginSession's remembered.delete and rememberPluginMessages's remembered.set) could capture an empty/stale snapshot and run executeProxyTool on the wrong context. The read now happens inside the lock, where rememberPluginMessages rewrites the snapshot atomically — so a racing tool call sees a consistent state.

3. src/plugin.ts recordPluginSession — drop the out-of-lock remembered.delete
The binding step ran remembered.delete(sessionId) outside the session lock — this was the source of the stale/empty window that #2 read into. remembered[sessionId] is now only mutated under the lock (rememberPluginMessages after forward). The conversations LRU is untouched.

Why it's safe

  • Normal plugin flow: tool calls arrive after the model response, never concurrent with a model request → behavior is unchanged.
  • All remembered access is now lock-protected (read in handlePluginTool, write in rememberPluginMessages, both under withSessionLock), so the state is consistent regardless of timing.

Verification

  • npm run typecheck clean
  • Full suite 453/453 pass, 0 fail (master baseline)
  • All 16 plugin tests pass, incl. plugin tool API executes compress under the session lock; next request folds the consumed range

… flag

Three defensive cleanups in the cooperative-plugin feature (merged via
#161 + #163), found during review:

1. src/mcp.ts: the 'registered' flag was checked in initialize
   (if (conversationId && !registered)) but never set to true, so the
   guard was dead and a repeated initialize would re-issue the register
   POST. Set registered = true once the register request is issued.
   (Register endpoints are idempotent, so no live harm — the guard is now
   actually functional.)

2. src/plugin.ts handlePluginTool: remembered.get(session.id) and the
   derived messages/nudge were read BEFORE withSessionLock. A tool call
   racing a concurrent model request (between recordPluginSession's
   remembered.delete and rememberPluginMessages's remembered.set) could
   capture an empty/stale snapshot and run executeProxyTool on the wrong
   context. The read now happens inside the lock, where
   rememberPluginMessages rewrites the snapshot atomically.

3. src/plugin.ts recordPluginSession: removed the out-of-lock
   remembered.delete(sessionId) (binding step). remembered[sessionId] is
   now only mutated under the session lock (rememberPluginMessages after
   forward), closing the stale window that #2 read into.

No behavior change in the normal plugin flow (tool calls arrive after the
model response, never concurrent with it); this is lock-consistency
hardening. Full suite 453/453 pass; typecheck clean.
@ranxianglei

Copy link
Copy Markdown
Owner Author

LGTM — this is the correct, minimal fix for a real race. Verified against src/plugin.ts and src/mcp.ts.

The bug: remembered.get(session.id) and the messages snapshot read were done in handlePluginTool outside the withSessionLock callback, while recordPluginSession (which can delete + re-add the remembered entry) ran from a different call path also touching the same map. A tool call could therefore read a stale/empty remembered snapshot (the entry deleted-but-not-yet-reinserted, or a prior run's snapshot), producing an empty/stale plugin context. Moving the remembered.get + messages read inside the withSessionLock callback (and dropping the out-of-lock remembered.delete(sessionId) from recordPluginSession) makes the read+write atomic under the same lock. Correct.

The mcp.ts addition (registered = true; // issue-once) is also correct and matches the flag the plugin handshake needs — a repeated initialize must not re-register.

Non-blocking nits:

  • mem.nudge is now also read inside the lock — that's correct (it's part of the same consistent snapshot), but worth a one-line comment that the whole RememberedSession read is the critical section, not just messages.
  • The PR is +16/-7 and touches only mcp.ts + plugin.ts, so it's isolated and low-risk. 453/453 pass.

Heads-up for sequencing: this PR adds registered = true to mcp.ts, which overlaps with the identical line in the open agent-plugins PR. Merge this one first (it's the small, self-contained correctness fix); the larger feature PR should drop its duplicate mcp.ts hunk on rebase so the locking change and the feature don't fight over the same line.

Merge.

@ranxianglei
ranxianglei merged commit 22177e5 into master Aug 21, 2026
5 checks passed
@ranxianglei ranxianglei mentioned this pull request Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant