Repository navigation
Teams connector token: promise-cache on the IO pool, not a SemaphoreSlim gate - #763
Merged
Merged
Conversation
…lim gate
TeamsClient held the last hand-woven async gate in the repo:
private readonly SemaphoreSlim _tokenGate = new(1, 1);
...
await _tokenGate.WaitAsync(ct); // double-checked lock around an HTTP token fetch
finally { _tokenGate.Release(); }
A SemaphoreSlim parks the awaiting thread. Awaited from a hub action block or a
grain turn that is a deadlock, and it bounds nothing the IO pool does not
already bound. It also escaped the usual grep: the target-typed `new(1, 1)`
form does not match "new SemaphoreSlim".
Replaced with the sanctioned promise-cache: the fetch runs via _httpPool.Run and
is cached in an INSTANCE ConcurrentDictionary keyed by the expiry being
replaced. Every caller racing on the same stale token shares ONE fetch and
replays its completion; a successful refresh moves the expiry, which is itself
the next key, and the finally-block drops the spent entry so the cache cannot
grow. Same single-flight guarantee, no gate, no parked thread.
Also moves every await inside a pool body: SendMessageAsync now composes
GetConnectorToken() with a pooled PostActivityAsync via SelectMany, leaving one
ToTask at the ITeamsClient boundary as the only Task in the file's public shape.
The pool is optional (IoPoolRegistry? -> IoPool.Unbounded) exactly as
AzureBlobStorageAdapter and InstanceOAuthService do it, so nothing else has to
change to construct one.
Verified: Memex.Portal.Shared builds Release -warnaserror with 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR replaces the last SemaphoreSlim-based “single-flight” gate in TeamsClient with an IO-pool-backed, promise-cached token refresh flow to avoid parking threads (which can deadlock hub/grain turns) and to keep outbound Teams HTTP work bounded by the Http IO pool.
Changes:
- Removed the
SemaphoreSlimtoken gate and introduced an IO-pool promise-cache for connector token refreshes. - Reworked
SendMessageAsyncto compose token fetch + activity POST reactively and keepawaitusage inside pooled leaves. - Added optional
IoPoolRegistryinjection withIoPool.Unboundedfallback.
Suppressed comments (1)
memex/Memex.Portal.Shared/Teams/TeamsClient.cs:137
- The cached-token fast path can throw if
_cachedTokenbecomes visible before_tokenExpiry(unsynchronized publication):_tokenExpiry.AddMinutes(-2)will underflow when_tokenExpiryis stillDateTimeOffset.MinValue. Also, once_tokenFetchstoresLazy<IObservable<...>>to avoid duplicate factories, this call site needs to unwrap.Value.
var cached = _cachedToken;
if (cached is not null && DateTimeOffset.UtcNow < _tokenExpiry.AddMinutes(-2))
return Observable.Return(cached);
var replacing = _tokenExpiry.Ticks;
return _tokenFetch.GetOrAdd(replacing, key => _httpPool.Run(poolCt => FetchConnectorTokenAsync(key, poolCt)));
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // being replaced, so every caller that sees the SAME stale token shares ONE fetch and replays | ||
| // its completion; a successful refresh moves the expiry, which is itself the next key. | ||
| private readonly IIoPool _httpPool; | ||
| private readonly ConcurrentDictionary<long, IObservable<string?>> _tokenFetch = new(); |
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
rbuergi
added a commit
that referenced
this pull request
Aug 27, 2026
The modify/delete conflict is core main updating a file this branch removes (4e1a05e, "22 Task bridges gone…"). Taking the deletion is only safe because MeshWeaver.Plugins#778 carried that exact delta into the plugins copy first — two files that never existed there plus this one's update. Re-verified at THIS merge, not at the earlier one, because the check is only worth what it measured: a full directory diff of core main's test/MeshWeaver.AI.Test against plugins main's src/MeshWeaver.AI.Test now differs in four files, all deliberate — three are `test/`→`src/` path strings (0 non-path diff lines each) and MeshPluginTest carries #763's RunTests conditional-by-contract adaptation. No content drift remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tz1i2fFcJhvR1Eje8yFH5F
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The find
TeamsClientheld the last hand-woven async gate in the repo:A
SemaphoreSlimparks the awaiting thread. Awaited from a hub action block or a grain turn that is a deadlock, and it bounds nothing the IO pool does not already bound.It escaped the usual audit because the target-typed
new(1, 1)form does not match agrep "new SemaphoreSlim"— worth knowing for future sweeps.The fix
The sanctioned promise-cache: the fetch runs via
_httpPool.Run(...), cached in an instanceConcurrentDictionarykeyed by the expiry being replaced. Callers racing on the same stale token share ONE fetch and replay its completion; a successful refresh moves the expiry, which becomes the next key, and thefinallydrops the spent entry so the cache cannot grow without bound.Same single-flight guarantee as the gate, with no parked thread.
awaitonly inside a pool bodySendMessageAsyncnow composesGetConnectorToken()with a pooledPostActivityAsyncviaSelectMany, leaving a singleToTaskat theITeamsClientboundary as the onlyTaskin the public shape. Both IO leaves — token fetch and activity POST — run on the HTTP pool.The pool is optional (
IoPoolRegistry?→IoPool.Unbounded), exactly asAzureBlobStorageAdapterandInstanceOAuthServicedo it, so no construction site has to change.Verification
dotnet build memex/Memex.Portal.Shared -c Release -warnaserror→ 0 warnings, 0 errors.Scope note
This is a genuine latent deadlock, but it is not the cause of the shard-1/3 CI race or the pre-warm false regressions —
TeamsClientis outbound Teams messaging and sits on neither path. Fixed on its own merits.Release note
Skipped — internal concurrency correctness, no user-visible change.
🤖 Generated with Claude Code