Skip to content

Replace polling with database-tick waits in PlatformAPI#88

Merged
KishanBagaria merged 13 commits into
mainfrom
codex/fs-tick-db-waits
May 24, 2026
Merged

Replace polling with database-tick waits in PlatformAPI#88
KishanBagaria merged 13 commits into
mainfrom
codex/fs-tick-db-waits

Conversation

@KishanBagaria
Copy link
Copy Markdown
Member

What

Replaces the fixed-interval polling loops in PlatformAPI (25ms / 250ms / exponential-backoff Task.sleep) for sent-message, sent-thread, and attachment-load waits with event-driven waits on the existing IMDatabase.changes Topic<Void> — the same debounced (25ms) filesystem-watcher tick the rest of the system already uses. Idle waits stop issuing wasted SQL queries; detection latency matches the old 25ms cadence.

New DatabaseTickWaits helper holds the three wait functions; each loops subscribe → query → satisfied? return : waitForChange(tick OR deadline). The subscribe-before-query ordering avoids lost wakeups. Topic gains onTermination-based subscription cleanup (keyed by UUID) and moves yield()/finish() out of the non-reentrant os_unfair_lock critical section.

Hardening (from eng review)

  • Backstop: waitForChange caps each wait at ~1s, so a dropped/coalesced FS tick re-queries at poll cadence instead of hanging to the full timeout (restores the old pollers' self-healing).
  • Graceful degrade: changeTopic() no longer fails the wait if beginListeningForChanges() throws — it logs and returns the (silent) topic; the backstop carries the wait.
  • Cancellation: sent-message/sent-thread waits run inline instead of Task.detached, so caller cancellation propagates and they stop promptly.
  • DRY: extracted ensureDatabase(_:) for lazy DB init.

Tests

DatabaseTickWaitTests — 11/11 passing:

  • Event-driven re-read (only re-queries on a tick) for all three helpers
  • Partial-link return after link timeout
  • Timeout/error paths: sent-message timeout-throw, sent-thread deadline-with-nil, attachment no-attachments / terminal-failure / timeout
  • Topic terminated-subscription cleanup + finishCurrentSubscribers deadlock guard

🤖 Generated with Claude Code

KishanBagaria and others added 2 commits May 23, 2026 19:28
…ellation, tests

- waitForChange caps each wait at a ~1s backstop so a dropped/coalesced FS
  tick re-queries at poll cadence instead of hanging to the full timeout
- changeTopic() degrades gracefully when beginListeningForChanges() throws:
  log and return the (silent) topic; the backstop carries the wait
- sentMessageIDs/sentThreadIDs waits run inline instead of Task.detached so
  caller cancellation propagates and they stop promptly
- extract ensureDatabase(_:) to dedupe lazy DB init
- add regression tests for the timeout/error paths in all three helpers and a
  finishCurrentSubscribers deadlock guard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 23, 2026 19:55
@indent
Copy link
Copy Markdown
Contributor

indent Bot commented May 23, 2026

PR Summary

Hardens the post-send / load-attachment "wait for DB tick" helpers so a missed FSEvent no longer hangs to the full timeout, watcher-setup failures degrade gracefully, and caller cancellation propagates. Three near-duplicate wait loops in PlatformAPI are consolidated into a new DatabaseTickWaits module, and Topic is reworked to auto-clean terminated subscribers and to avoid a re-entrant os_unfair_lock deadlock in finishCurrentSubscribers.

  • New DatabaseTickWaits (sentMessageIDs / sentThreadIDs / loadedAttachment) that subscribes to the change Topic before each query and waits with a min(remaining, 1s) backstop sleep via a TaskGroup.
  • PlatformAPIDatabase gains a State struct (database, isListeningForChanges) and a changeTopic() accessor that lazily starts FS watchers and silently falls back to backstop polling if beginListeningForChanges() throws.
  • waitForSentMessageIDs / waitForSentThreadIDs now run inline instead of Task.detached, so caller cancellation propagates promptly.
  • Topic switches subscriptions to (UUID, Continuation), removes itself on cont.onTermination, copies continuations out before broadcast/finishCurrentSubscribers to avoid lock re-entry, and exposes package var subscriptionCount.
  • Regression tests for timeout/error paths on all three helpers, a finishCurrentSubscribers no-deadlock guard, and a subscription-cleanup test on Topic.

Issues

All clear! No issues remaining. 🎉

4 issues already resolved
  • changeTopic() allows two concurrent first callers to both pass the .notStarted gate and run db.beginListeningForChanges() in parallel — data race on IMDatabase.fileWatchers/debouncer, and worse: the "idempotent" teardown doesn't invalidate the prior directoryWatcher (it's only a local), so the old FSEventsWatcher stays alive via its C retain and its callback keeps mutating self.fileWatchers from fsEventsQueue alongside the new setup. (fixed by commit a375898)
  • changeTopic() holds the single state lock across db.beginListeningForChanges(), which starts FSEvents + DispatchSource watchers; that same lock serializes every withDatabase call, so any concurrent DB query blocks behind the first wait that triggers watcher setup. (fixed by commit 737c1fa)
  • ensureDatabase always writes back to state.database even on a cache hit — harmless but redundant. (fixed by commit 3fbb0e4)
  • Watcher-setup failure in changeTopic() is silent except for a log line — if beginListeningForChanges() keeps throwing, every send/attachment wait quietly pays ~1s per poll with no metric or reportErrorMessage to surface the degraded mode. (fixed by commit 737c1fa)

CI Checks

Waiting for CI checks...

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replace polling loops with tick/backstop-driven waits: Topic subscriptions use UUID-keyed continuations; DatabaseTickWaits provides sentMessageIDs, sentThreadIDs, loadedAttachment, and waitForChange; PlatformAPI and IMDatabase integrate these changes; tests cover success, timeout, cancellation, and failure modes.

Changes

Tick-driven database waiting system

Layer / File(s) Summary
Topic UUID-keyed subscription tracking
src/IMessage/Sources/IMessageCore/Topic.swift
Topic stores subscriptions with UUID identifiers, allowing targeted removal when subscribers terminate via onTermination handlers. broadcast() snapshots continuations before yielding outside the lock. finishCurrentSubscribers() snapshots and clears under lock, then finishes captured continuations. Added subscriptionCount.
DatabaseTickWaits tick-driven waiting helpers
src/IMessage/Sources/IMessage/DatabaseTickWaits.swift
New utility defining backstop/deadline constants and implementing sentMessageIDs, sentThreadIDs, loadedAttachment, and waitForChange. Each helper subscribes to a change Topic, re-queries state on ticks or backstop intervals, and returns or throws based on expected conditions, terminal failure states, or timeouts.
PlatformAPI refactoring to tick-driven waits
src/IMessage/Sources/IMessage/PlatformAPI.swift
PlatformAPIDatabase uses a protected State with lazy changeTopic() to begin listening once. Replaced polling/deadline loops in waitForLoadedAttachment, waitForSentMessageIDs, and waitForSentThreadIDs with DatabaseTickWaits calls driven by database.changeTopic(). Removed polling constants and Task.detached usage so caller cancellation propagates.
IMDatabase listener setup resilience
src/IMessage/Sources/IMDatabase/Database/IMDatabase.swift
beginListeningForChanges() is made idempotent and cleans up partially-created watchers/debouncer state on failure; listener lifecycle is synchronized via listenerLock and the WAL directory watcher is owned on the instance.
DatabaseTickWaits and Topic behavior tests
src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift
Async tests added for Topic subscription removal, tick-driven re-queries for sentMessageIDs/sentThreadIDs/loadedAttachment, timeout and terminal-failure paths, cancellation behavior, and a regression test ensuring finishCurrentSubscribers() does not deadlock. Includes test helpers for messages and eventual polling.

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Replace polling with database-tick waits in PlatformAPI' accurately and concisely describes the main change: replacing polling loops with event-driven waits on database ticks.
Description check ✅ Passed The description clearly explains what changes were made, why (replacing polling with event-driven waits), hardening improvements, and test coverage, all directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fs-tick-db-waits

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 several fixed-interval polling loops in PlatformAPI with event-driven waits that block on IMDatabase.changes ticks (with a ~1s backstop recheck), reducing idle SQL querying while preserving similar detection latency.

Changes:

  • Introduces DatabaseTickWaits to implement tick-driven “subscribe → query → waitForChange(or backstop) → retry” wait loops for sent-message IDs, sent-thread IDs, and attachment loading.
  • Updates PlatformAPI to use DatabaseTickWaits and adds PlatformAPIDatabase.changeTopic() + ensureDatabase(_:) for lazy DB init and change-topic setup (with graceful fallback if change listening fails).
  • Enhances Topic to clean up terminated subscriptions (UUID-keyed) and avoid lock reentrancy issues by moving yield()/finish() outside the Protected lock; adds focused tests for these behaviors.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift Adds coverage for tick-driven re-query semantics, timeout/partial-return paths, and Topic subscription cleanup/deadlock regression checks.
src/IMessage/Sources/IMessageCore/Topic.swift Adds on-termination subscription cleanup and changes broadcast/finish to operate outside the lock to avoid deadlocks.
src/IMessage/Sources/IMessage/PlatformAPI.swift Replaces polling-based waits with DatabaseTickWaits and adds change-topic acquisition with graceful fallback behavior.
src/IMessage/Sources/IMessage/DatabaseTickWaits.swift New helper implementing tick-based waits with a bounded backstop interval to avoid hanging on missed/coalesced ticks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/IMessage/Sources/IMessage/PlatformAPI.swift Outdated
Comment thread src/IMessage/Sources/IMessage/PlatformAPI.swift Outdated
Comment thread src/IMessage/Sources/IMessage/PlatformAPI.swift Outdated
indent Bot and others added 2 commits May 23, 2026 20:05
Return early when state.database is already set so cache hits don't
re-store the same reference under the lock.

Generated with [Indent](https://indent.com)
Co-Authored-By: KishanBagaria <KishanBagaria@users.noreply.github.com>
…setup, off-lock changeTopic

- test the 1.0s backstop re-query path with no FS tick (injectable backstop interval)
- test caller-cancellation propagation through DatabaseTickWaits (prompt throw + subscription cleanup)
- test loadedAttachment's message-not-found throw path
- changeTopic(): run beginListeningForChanges() outside the DB lock; re-acquire only to record state
- replace isListeningForChanges bool with ListeningState (notStarted/listening/failed): log once and accept backstop-degraded mode instead of retrying setup on every send
- make beginListeningForChanges() idempotent: cancel the prior debouncer Task and tear down partial watcher state on throw

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/IMessage/Sources/IMessage/PlatformAPI.swift Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/IMessage/Sources/IMDatabase/Database/IMDatabase.swift`:
- Around line 111-123: The FSEventsWatcher is currently a local variable named
directoryWatcher inside beginListeningForChanges(), so IMDatabase does not
retain it and events or teardown fail; make FSEventsWatcher a stored property on
IMDatabase (alongside fileWatchers), assign the newly created watcher to that
property instead of only the local directoryWatcher, update
cleanupPartialSetup() and any other teardown paths (including deinit and the
other beginListeningForChanges() block around lines 133-163) to stop/invalidate
and nil out self.directoryWatcher when cleaning up or replacing it, and ensure
the method uses the stored property for lifecycle management rather than a local
variable.

In `@src/IMessage/Sources/IMessage/PlatformAPI.swift`:
- Around line 23-53: The race comes from two callers both observing listening ==
.notStarted and each calling db.beginListeningForChanges(), which mutates shared
IMDatabase state; fix it by reserving the setup slot under the lock: inside
changeTopic() use state.withLock to change listening from .notStarted to a
transient .starting (or similar) and return (db, shouldSetUp) accordingly so
only the thread that successfully transitions .notStarted -> .starting performs
beginListeningForChanges(); other callers seeing .starting should skip calling
db.beginListeningForChanges() and just return db.changes. After a successful
beginListeningForChanges() set listening = .listening under the lock, and on
error set listening = .failed (as before), ensuring ensureDatabase, changeTopic,
state.withLock, beginListeningForChanges, and the listening state transitions
are the only symbols you change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 28357d31-89cf-45b6-b457-03a7008a2c7e

📥 Commits

Reviewing files that changed from the base of the PR and between 3fbb0e4 and 737c1fa.

📒 Files selected for processing (4)
  • src/IMessage/Sources/IMDatabase/Database/IMDatabase.swift
  • src/IMessage/Sources/IMessage/DatabaseTickWaits.swift
  • src/IMessage/Sources/IMessage/PlatformAPI.swift
  • src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-05-03T17:00:19.662Z
Learnt from: KishanBagaria
Repo: beeper/platform-imessage PR: 69
File: src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift:89-93
Timestamp: 2026-05-03T17:00:19.662Z
Learning: In the beeper/platform-imessage Swift codebase, keep message IDs (`PlatformSDK.MessageID`) as raw GUIDs. When mapping from DB/event rows to `message.id`, set the ID directly from `msgRow.guid` (no GUID→public-ID hashing or transformation). For multi-part messages, append the part index as `_<part.index>` to the GUID-derived ID. During code review, if changes touch message ID creation/mapping, ensure this raw GUID + optional `_<part.index>` suffix behavior is preserved.

Applied to files:

  • src/IMessage/Sources/IMDatabase/Database/IMDatabase.swift
  • src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift
  • src/IMessage/Sources/IMessage/DatabaseTickWaits.swift
  • src/IMessage/Sources/IMessage/PlatformAPI.swift
🪛 SwiftLint (0.63.2)
src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift

[Warning] 54-54: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 83-83: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 234-234: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)

Comment thread src/IMessage/Sources/IMDatabase/Database/IMDatabase.swift Outdated
Comment thread src/IMessage/Sources/IMessage/PlatformAPI.swift
KishanBagaria and others added 3 commits May 24, 2026 02:17
changeTopic() ran beginListeningForChanges() outside the state lock and let
two callers both observe .notStarted, so two threads could concurrently mutate
IMDatabase's unsynchronized fileWatchers array and debouncer. Claim the setup
slot atomically (.notStarted -> .starting) under the lock so only one caller
runs setup; the loser returns the topic and rides the backstop.

Also assign directoryWatcherOut only after start() succeeds, so the
partial-setup cleanup can't stop/invalidate a never-started FSEventStream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retain the directory watcher so repeated listener setup can stop it
alongside file watchers and the debouncer. Serialize listener setup and
filesystem callback refreshes to avoid racing watcher state.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/IMessage/Sources/IMessage/DatabaseTickWaits.swift`:
- Around line 145-148: The early return in
waitForChange(on:until:backstopInterval:) when remainingTime <= 0 leaves the
AsyncStream subscription dangling; change the implementation so that instead of
returning immediately it consumes a single element from the provided
AsyncStream<Void> (i.e., await one tick from the stream) before returning to
ensure the subscription is completed, or alternatively add explicit
cancellation/unsubscription logic at the caller (waitForDatabaseResult) if you
prefer to handle teardown there; locate waitForChange and modify the early-exit
path to await a single stream element (or call the caller-level unsubscription)
so the AsyncStream subscription is not leaked.
- Around line 127-143: waitForDatabaseResult creates a subscription via
changes.subscribe() (bound to changeStream) but returns immediately when
evaluate(...) yields .finished, leaving the AsyncStream continuation in
Topic.subscriptions and leaking; fix by introducing an RAII-style subscription
guard (e.g., SubscriptionGuard) that wraps changes.subscribe(), exposes the
stream (e.g., guard.stream) and guarantees on deinit or explicit close() that
the underlying subscription is cancelled/consumed, then use this guard in
waitForDatabaseResult instead of a raw changeStream so that when evaluate
returns .finished the guard is dropped/closed and the subscription is removed;
ensure waitForChange continues to accept guard.stream and that guard has an
explicit close() called before returning the finished value if necessary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9212785a-df21-47a1-884a-76f33874d373

📥 Commits

Reviewing files that changed from the base of the PR and between a375898 and d04d9dc.

📒 Files selected for processing (3)
  • src/IMessage/Sources/IMessage/DatabaseTickWaits.swift
  • src/IMessage/Sources/IMessageCore/Topic.swift
  • src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-05-03T17:00:19.662Z
Learnt from: KishanBagaria
Repo: beeper/platform-imessage PR: 69
File: src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift:89-93
Timestamp: 2026-05-03T17:00:19.662Z
Learning: In the beeper/platform-imessage Swift codebase, keep message IDs (`PlatformSDK.MessageID`) as raw GUIDs. When mapping from DB/event rows to `message.id`, set the ID directly from `msgRow.guid` (no GUID→public-ID hashing or transformation). For multi-part messages, append the part index as `_<part.index>` to the GUID-derived ID. During code review, if changes touch message ID creation/mapping, ensure this raw GUID + optional `_<part.index>` suffix behavior is preserved.

Applied to files:

  • src/IMessage/Sources/IMessageCore/Topic.swift
  • src/IMessage/Sources/IMessage/DatabaseTickWaits.swift
  • src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift
🔇 Additional comments (6)
src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift (1)

1-325: LGTM!

src/IMessage/Sources/IMessageCore/Topic.swift (1)

7-7: LGTM!

Also applies to: 17-24, 26-39, 50-58

src/IMessage/Sources/IMessage/DatabaseTickWaits.swift (4)

20-59: LGTM!


61-82: LGTM!


84-125: LGTM!


151-167: LGTM!

Comment on lines +127 to +143
private static func waitForDatabaseResult<T>(
changes: Topic<Void>,
backstopInterval: TimeInterval,
query: @escaping @Sendable () async throws -> T,
evaluate: (T) async throws -> WaitResult<T>
) async throws -> T {
while true {
let changeStream = changes.subscribe()
let result = try await query()
switch try await evaluate(result) {
case let .finished(value):
return value
case let .waitingUntil(deadline):
try await waitForChange(on: changeStream, until: deadline, backstopInterval: backstopInterval)
}
}
}
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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Subscription leak when query succeeds without waiting.

When evaluate returns .finished on the first query attempt, the subscription created at line 134 is never iterated—waitForChange is skipped. Since AsyncStream.onTermination only fires when the stream is iterated, finished, or the iterating task is cancelled, the continuation remains in Topic.subscriptions indefinitely.

Over time, these dangling subscriptions accumulate: each broadcast() will yield to orphaned continuations with .unbounded buffering, causing unbounded memory growth.

Proposed fix: Wrap stream in RAII-style cleanup

Introduce a small wrapper that ensures the stream is consumed/cancelled on scope exit:

+    private struct ScopedSubscription {
+        let stream: AsyncStream<Void>
+        private var iterator: AsyncStream<Void>.AsyncIterator?
+        
+        init(_ stream: AsyncStream<Void>) {
+            self.stream = stream
+        }
+        
+        mutating func consume() async {
+            if iterator == nil {
+                iterator = stream.makeAsyncIterator()
+            }
+            _ = await iterator?.next()
+        }
+    }
+
     private static func waitForDatabaseResult<T>(
         changes: Topic<Void>,
         backstopInterval: TimeInterval,
         query: `@escaping` `@Sendable` () async throws -> T,
         evaluate: (T) async throws -> WaitResult<T>
     ) async throws -> T {
         while true {
-            let changeStream = changes.subscribe()
+            var subscription = ScopedSubscription(changes.subscribe())
+            defer {
+                // Start iteration so onTermination fires when scope exits
+                Task { [subscription] in
+                    var sub = subscription
+                    _ = await sub.stream.makeAsyncIterator().next()
+                }
+            }
             let result = try await query()
             switch try await evaluate(result) {
             case let .finished(value):
                 return value
             case let .waitingUntil(deadline):
-                try await waitForChange(on: changeStream, until: deadline, backstopInterval: backstopInterval)
+                await subscription.consume()
+                try await waitForChange(on: subscription.stream, until: deadline, backstopInterval: backstopInterval)
             }
         }
     }

Alternatively, add explicit unsubscribe support to Topic (e.g., subscribe() -> (stream, unsubscribe: () -> Void)).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/IMessage/Sources/IMessage/DatabaseTickWaits.swift` around lines 127 -
143, waitForDatabaseResult creates a subscription via changes.subscribe() (bound
to changeStream) but returns immediately when evaluate(...) yields .finished,
leaving the AsyncStream continuation in Topic.subscriptions and leaking; fix by
introducing an RAII-style subscription guard (e.g., SubscriptionGuard) that
wraps changes.subscribe(), exposes the stream (e.g., guard.stream) and
guarantees on deinit or explicit close() that the underlying subscription is
cancelled/consumed, then use this guard in waitForDatabaseResult instead of a
raw changeStream so that when evaluate returns .finished the guard is
dropped/closed and the subscription is removed; ensure waitForChange continues
to accept guard.stream and that guard has an explicit close() called before
returning the finished value if necessary.

Comment thread src/IMessage/Sources/IMessage/DatabaseTickWaits.swift
@KishanBagaria KishanBagaria merged commit d35f8db into main May 24, 2026
3 of 4 checks passed
@KishanBagaria KishanBagaria deleted the codex/fs-tick-db-waits branch May 24, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants