fix(plugin): harden remembered-snapshot locking + activate registered flag - #171
Conversation
… 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.
|
LGTM — this is the correct, minimal fix for a real race. Verified against The bug: The Non-blocking nits:
Heads-up for sequencing: this PR adds Merge. |
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 deadregisteredflagThe
registeredflag was checked ininitialize(if (conversationId && !registered)) but never set totrue, so the guard was inert and a repeatedinitializewould re-issue the register POST. Nowregistered = trueis 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.tshandlePluginTool— readrememberedunder the session lockremembered.get(session.id)and the derivedmessages/nudgewere read beforewithSessionLock. A tool call racing a concurrent model request (which sits betweenrecordPluginSession'sremembered.deleteandrememberPluginMessages'sremembered.set) could capture an empty/stale snapshot and runexecuteProxyToolon the wrong context. The read now happens inside the lock, whererememberPluginMessagesrewrites the snapshot atomically — so a racing tool call sees a consistent state.3.
src/plugin.tsrecordPluginSession— drop the out-of-lockremembered.deleteThe 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 (rememberPluginMessagesafterforward). TheconversationsLRU is untouched.Why it's safe
rememberedaccess is now lock-protected (read inhandlePluginTool, write inrememberPluginMessages, both underwithSessionLock), so the state is consistent regardless of timing.Verification
npm run typecheckcleanplugin tool API executes compress under the session lock; next request folds the consumed range