#22454: fix(macos): add re-subscribe loop to gateway stream subscribers
app: macos
size: XS
Cluster:
Fix Microsoft Teams Plugin Issues
## Summary
Wraps the `GatewayConnection.shared.subscribe()` + `for await` pattern in a `while !Task.isCancelled` loop with a 1-second backoff for three gateway event subscribers:
- `ExecApprovalsGatewayPrompter`
- `DevicePairingApprovalPrompter`
- `NodePairingApprovalPrompter`
## Problem
If the underlying `AsyncStream` from `GatewayConnection.subscribe()` ever terminates, these prompters silently stop receiving gateway events and never recover. This can happen if `GatewayConnection.shutdown()` is later changed to properly `finish()` subscriber continuations, or if the stream ends for any other lifecycle reason.
Currently the streams never terminate (continuations are not finished in `shutdown()`), so the prompters hang in a zombie state — blocked on `for await` but receiving nothing — rather than crashing. This makes the bug silent and hard to diagnose.
## Fix
Add the same defensive re-subscribe loop already used by `VoiceWakeGlobalSettingsSync`:
```swift
while !Task.isCancelled {
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
// handle push
}
if Task.isCancelled { return }
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s backoff
}
```
When the stream ends, the subscriber logs, waits 1 second, and re-subscribes. Task cancellation (via each prompter's existing `stop()` method) cleanly breaks the loop.
## Behavioral impact
**None under current code.** The `for await` loop blocks indefinitely today because `GatewayConnection.shutdown()` does not finish subscriber continuations. The outer `while` loop never iterates. This change is purely defensive/future-proofing.
## Testing
- `swift build` succeeds (265 targets, 43s)
- No new test failures introduced (pre-existing test compile failures in `GatewayChannelShutdownTests` and `GatewayChannelRequestTests` are unrelated — `sendPing` protocol conformance was added to `WebSocketTasking` but test fakes were not updated)
- Diff is minimal: +26 lines / -9 lines across 3 files
- Matches established pattern in `VoiceWakeGlobalSettingsSync`
## Related
The approval prompters are high-impact subscribers (exec approvals, device pairing, node pairing). Unlike `CronJobsStore` or `InstancesStore` which have polling fallbacks, these prompters rely entirely on the gateway event stream to trigger native macOS alert prompts.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Added defensive re-subscribe loops to three gateway event subscribers (`ExecApprovalsGatewayPrompter`, `DevicePairingApprovalPrompter`, `NodePairingApprovalPrompter`) following the existing pattern from `VoiceWakeGlobalSettingsSync`.
**Pattern implemented:**
- Wrapped `subscribe()` + `for await` in a `while !Task.isCancelled` loop
- Added 1-second backoff between re-subscribe attempts
- Task cancellation checks at loop boundaries prevent zombie tasks
**Impact:**
- Currently zero behavioral impact because `GatewayConnection.shutdown()` does not call `finish()` on subscriber continuations, so streams never terminate naturally
- Future-proofs against stream termination if gateway lifecycle changes (e.g., if `shutdown()` is later modified to properly finish continuations)
- Prevents silent failure if the underlying `AsyncStream` ends for any reason
**Consistency notes:**
- `ExecApprovalsGatewayPrompter` adds logging and detailed comments (good practice)
- `DevicePairingApprovalPrompter` and `NodePairingApprovalPrompter` lack the logging statement that `ExecApprovalsGatewayPrompter` includes when the stream ends
- All three use 1-second backoff, matching the PR description and established pattern
- `VoiceWakeGlobalSettingsSync` uses a different backoff (600ms) for its re-subscribe loop, but that's intentional as it handles different event types
<h3>Confidence Score: 5/5</h3>
- This PR is safe to merge with minimal risk
- The changes are minimal, defensive, and follow an established pattern from `VoiceWakeGlobalSettingsSync`. Current behavior is unchanged (streams never terminate because `shutdown()` doesn't finish continuations), so this is purely future-proofing. The pattern correctly handles task cancellation at all loop boundaries, preventing resource leaks. The only minor issue is missing debug logging in two files (non-critical).
- No files require special attention
<sub>Last reviewed commit: cd68611</sub>
<!-- greptile_other_comments_section -->
<sub>(2/5) Greptile learns from your feedback when you react with thumbs up/down!</sub>
<!-- /greptile_comment -->
Most Similar PRs
#21555: fix: abort streaming runs after 90s of inactivity
by jg-noncelogic · 2026-02-20
74.5%
#6302: fix: Add timeouts to prevent indefinite hangs (issues #4954, #4956,...
by batumilove · 2026-02-01
73.4%
#12953: fix: defer gateway restart until all replies are sent
by zoskebutler · 2026-02-10
73.2%
#22367: fix(whatsapp): prevent permanent listener loss after abort during r...
by mcinteerj · 2026-02-21
72.8%
#22605: fix(msteams): keep provider promise pending until abort to stop aut...
by OpakAlex · 2026-02-21
72.8%
#17265: fix: abort streaming runs after 90s of inactivity
by jg-noncelogic · 2026-02-15
72.7%
#20475: fix(macos): resolve 120%+ CPU regression and gateway stability
by teknomage8 · 2026-02-19
72.6%
#16994: fix(gateway): prevent double terminal SSE event on OpenResponses error
by AI-Reviewer-QS · 2026-02-15
72.5%
#10731: fix(discord): add outer retry loop for gateway reconnect exhaustion
by Milofax · 2026-02-06
72.3%
#20455: fix(msteams): prevent EADDRINUSE restart loop
by taradtke · 2026-02-18
72.1%