Skip to content

Teams connector token: promise-cache on the IO pool, not a SemaphoreSlim gate - #763

Merged
rbuergi merged 1 commit into
mainfrom
fix/teams-token-pool
Aug 3, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/teams-token-pool

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The find

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 escaped the usual audit because the target-typed new(1, 1) form does not match a grep "new SemaphoreSlim" — worth knowing for future sweeps.

The fix

The sanctioned promise-cache: the fetch runs via _httpPool.Run(...), cached in an instance ConcurrentDictionary keyed 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 the finally drops the spent entry so the cache cannot grow without bound.

Same single-flight guarantee as the gate, with no parked thread.

await only inside a pool body

SendMessageAsync now composes GetConnectorToken() with a pooled PostActivityAsync via SelectMany, leaving a single ToTask at the ITeamsClient boundary as the only Task in the public shape. Both IO leaves — token fetch and activity POST — run on the HTTP pool.

The pool is optional (IoPoolRegistry? → IoPool.Unbounded), exactly as AzureBlobStorageAdapter and InstanceOAuthService do 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 — TeamsClient is 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

…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>
Copilot AI review requested due to automatic review settings August 3, 2026 06:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SemaphoreSlim token gate and introduced an IO-pool promise-cache for connector token refreshes.
  • Reworked SendMessageAsync to compose token fetch + activity POST reactively and keep await usage inside pooled leaves.
  • Added optional IoPoolRegistry injection with IoPool.Unbounded fallback.
Suppressed comments (1)

memex/Memex.Portal.Shared/Teams/TeamsClient.cs:137

  • The cached-token fast path can throw if _cachedToken becomes visible before _tokenExpiry (unsynchronized publication): _tokenExpiry.AddMinutes(-2) will underflow when _tokenExpiry is still DateTimeOffset.MinValue. Also, once _tokenFetch stores Lazy<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();
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

966 tests  ±0   960 ✅ ±0   4m 56s ⏱️ -5s
  5 suites ±0     6 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 066 tests  ±0   963 ✅ ±0   5m 43s ⏱️ +30s
   10 suites ±0   103 💤 ±0 
   10 files   ±0     0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   12 files  ±0     12 suites  ±0   4m 8s ⏱️ +7s
1 016 tests ±0  1 012 ✅ ±0  4 💤 ±0  0 ❌ ±0 
1 086 runs  ±0  1 082 ✅ ±0  4 💤 ±0  0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 202 tests  ±0   1 201 ✅ ±0   6m 28s ⏱️ +17s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 137 tests  ±0   1 949 ✅ ±0   5m 12s ⏱️ +5s
   12 suites ±0     188 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 226 tests  ±0   1 225 ✅ ±0   7m 55s ⏱️ -19s
   10 suites ±0       1 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Test Results

   61 files  ±0     61 suites  ±0   34m 24s ⏱️ +34s
7 613 tests ±0  7 310 ✅ ±0  303 💤 ±0  0 ❌ ±0 
7 683 runs  ±0  7 380 ✅ ±0  303 💤 ±0  0 ❌ ±0 

Results for commit b15dd40. ± Comparison against base commit 4e48d4f.

@rbuergi
rbuergi merged commit 362a5de into main Aug 3, 2026
16 checks passed
@rbuergi
rbuergi deleted the fix/teams-token-pool branch August 4, 2026 13:34
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
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.

2 participants