Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications#1689
Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications#1689Copilot wants to merge 5 commits into
Conversation
… notifications - Add _deferralDepth and _pendingChange int fields for thread-safe deferral tracking - Update RaiseChanged() to defer notification when _deferralDepth > 0 - Add DeferChanges() public method returning a DeferralScope : IDisposable - Add EndDeferral() private method using Interlocked ops for atomicity - Add unit tests: no-mutation scope, single/multi-mutations, mixed add+remove, Clear, nested scopes, idempotent dispose, no-handler case, baseline behavior Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
- Rename DeferralScope to ChangeDeferralScope - Add preexisting Changed behavior tests (TryAdd, Remove, Clear -- with and without success) - Add DeferChanges tests for duplicate TryAdd (with and without prior entry) - Add DeferChanges test for TryAdd-then-Remove same tool (fires one Changed even though net content is unchanged) Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
tarekgh
left a comment
There was a problem hiding this comment.
Faithful implementation of the agreed design, and it picks up the gate-at-RaiseChanged and net-zero-fires points. One correctness issue on the concurrency handling, plus a small doc gap, noted inline.
| if (Volatile.Read(ref _deferralDepth) > 0) | ||
| { | ||
| Interlocked.Exchange(ref _pendingChange, 1); | ||
| return; | ||
| } |
There was a problem hiding this comment.
The deferral check here and EndDeferral are not atomic together, so a mutation racing with disposal of the last scope can be dropped. If this line reads depth > 0, but EndDeferral then runs Decrement to 0 and Exchange(_pendingChange, 0) before line 79 sets _pendingChange to 1, the pending change is left set with no active scope to flush it, so no notification is ever sent. That is exactly the missed list_changed this PR aims to prevent, so the Interlocked/Volatile approach does not fully deliver the thread-safety claim.
Suggest guarding the depth/pending transitions with a small lock and invoking Changed outside it. Mutation frequency is low, so the lock only wraps a few integer ops and does not affect ConcurrentDictionary throughput:
private readonly object _deferralLock = new();
protected void RaiseChanged()
{
lock (_deferralLock)
{
if (_deferralDepth > 0) { _pendingChange = true; return; }
}
Changed?.Invoke(this, EventArgs.Empty);
}
private void EndDeferral()
{
bool raise;
lock (_deferralLock)
{
raise = --_deferralDepth == 0 && _pendingChange;
if (raise) _pendingChange = false;
}
if (raise) Changed?.Invoke(this, EventArgs.Empty);
}DeferChanges() should increment _deferralDepth under the same lock. A multithreaded test (N threads adding inside one shared scope, asserting a single Changed) would lock this in.
There was a problem hiding this comment.
Fixed in the latest commit. Replaced the Interlocked/Volatile approach with a lock (_deferralLock) guard around all _deferralDepth and _pendingChange transitions -- DeferChanges(), RaiseChanged(), and EndDeferral(). _pendingChange is now a bool rather than an int. The Changed invocation still happens outside the lock. The concurrency tests (DeferChanges_ConcurrentMutations_FiresExactlyOneChanged and DeferChanges_MutationRacingWithDispose_NotificationNotLost) lock this in.
| /// the deferral is ended on dispose. If any mutation occurred before the exception, a single | ||
| /// <see cref="Changed"/> notification is raised. | ||
| /// </remarks> | ||
| public IDisposable DeferChanges() |
There was a problem hiding this comment.
Please add a remark that mutations from any thread during an open scope are coalesced, and the single notification fires when the outermost scope disposes. The concurrent behavior is asserted in the PR description but not documented on the API.
There was a problem hiding this comment.
Done in the latest commit -- added a <para> block to the <remarks> on DeferChanges() documenting that mutations from any thread during an open scope are coalesced, the single notification fires on the thread that disposes the outermost scope, and that all deferral state transitions are guarded by an internal lock.
|
A couple of tests are missing to back the review points, plus a few worth adding: Required:
Recommended:
|
|
@copilot Please address the following excellent feedback from @tarekgh:
|
Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
All addressed in the latest commit:
|
| /// concurrent mutations and concurrent scope disposal are both safe. | ||
| /// </para> | ||
| /// </remarks> | ||
| public IDisposable DeferChanges() |
There was a problem hiding this comment.
The naming makes this sound like mutations to the collection are being deferred until the scope closes. Something like DeferChangedEvents might be clearer.
| private readonly object _deferralLock = new(); | ||
|
|
||
| /// <summary>Depth counter for active <see cref="DeferChanges"/> scopes. Positive means notifications are deferred.</summary> | ||
| private int _deferralDepth; |
There was a problem hiding this comment.
_activeDeferralScopes seems more self-describing
Fixes #1688
Registering a batch of N tools/prompts/resources emits N
list_changedwire notifications -- one perTryAdd-- instead of one. This can trip clients that debounce or rate-limit notifications, and is wasteful for progressive-disclosure patterns.Changes
DeferChanges()onMcpServerPrimitiveCollection<T>-- returns anIDisposablescope that suppressesChangedevents. A single notification fires when the outermost scope disposes, only if at least one mutation occurred. Nesting is supported via a depth counter._deferralDepthand_pendingChangeare coordinated withInterlocked/Volatile;RaiseChanged()defers when depth > 0, and theDeferralScopedispose is idempotent viaInterlocked.Exchange.Clear, nested scopes, idempotent dispose, no-handler, and baseline non-deferred behavior.Usage
Purely additive -- no behavior change for callers that never call
DeferChanges(). Applies uniformly to tool, prompt, and resource collections.