Skip to content

refactor!: add TaskTracker for structured task lifecycle management - #458

Closed
jlucaso1 wants to merge 4 commits into
mainfrom
refactor/task-tracker
Closed

refactor!: add TaskTracker for structured task lifecycle management#458
jlucaso1 wants to merge 4 commits into
mainfrom
refactor/task-tracker

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add TaskTracker to wacore::runtime — a lightweight container that stores AbortHandles and aborts them all on demand or when dropped
  • Add #[must_use] to AbortHandle so the compiler warns when a spawned task's handle is silently dropped (forces explicit .detach() or tracking)
  • Add connection_tasks / client_tasks fields to Client:
    • connection_tasks: tracked for diagnostics and drained at the start of each connect() call to prevent unbounded handle accumulation across reconnects. Tasks exit cooperatively via connection_generation, is_shutting_down(), and shutdown_notifier — no forceful abort on disconnect (tasks like the post-login sequence must survive 515 reconnect cycles)
    • client_tasks: aborted when Client drops — covers LID-PN cache warm-up, device registry cleanup, persistence background saver
  • Invalidate message_queues on disconnect (bug fix: stale per-chat workers survived reconnects)
  • run_background_saver now returns AbortHandle instead of detaching internally
  • 14 .detach() calls remain as intentional fire-and-forget (event dispatch callbacks, best-effort acks/receipts, transport disconnect teardown, background DB writes, sync worker with Weak ref, per-stanza processing)

Breaking changes

  • PersistenceManager::run_background_saver() now returns AbortHandle instead of (). Callers must either track the handle (e.g., via client_tasks.track()) or call .detach() to preserve the old fire-and-forget behavior.

Test plan

  • cargo clippy --all --tests — zero warnings
  • cargo test --all --exclude e2e-tests — all 1090 tests pass
  • Verified #[must_use] produces a warning when runtime.spawn() result is unused
  • Verified remaining 14 .detach() calls are all intentional fire-and-forget
  • Verified connection_tasks.abort_all() is called at start of connect() (not during cleanup_connection_state) to avoid aborting the post-login task during 515 reconnect cycles
  • Verified message_queues.invalidate_all() closes channels so per-chat workers exit
  • Verified cooperative cancellation via connection_generation / shutdown_notifier handles staleness

Summary by CodeRabbit

New Features

  • Added background task activity monitoring to diagnostics output

Improvements

  • Enhanced cleanup of stale background tasks during connection state transitions
  • Improved background task lifecycle management by assigning appropriate ownership scopes
  • Refined task handling during reconnection cycles to prevent leftover operations from prior sessions

@coderabbitai

coderabbitai Bot commented Mar 28, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds a TaskTracker type and makes background tasks return/produce AbortHandles; many fire-and-forget runtime spawns are replaced with TaskTracker-managed spawns, the client gains separate connection_tasks and client_tasks trackers, and the persistence background saver now returns an AbortHandle that's tracked by the client.

Changes

Cohort / File(s) Summary
Runtime & TaskTracker
wacore/src/runtime.rs
Add TaskTracker (new public type), #[must_use] on AbortHandle, spawn helpers, Default/Drop and APIs to track/abort tasks.
Persistence background saver (wacore)
wacore/src/store/persistence.rs
run_background_saver now returns an AbortHandle instead of detaching the spawned task.
Persistence manager (client-side)
src/store/persistence_manager.rs
Mirror change: run_background_saver returns the AbortHandle produced by runtime spawn.
Bot initialization
src/bot.rs
Capture saver handle from persistence manager and register it via client.client_tasks.track(...).
Client task trackers & diagnostics
src/client.rs
Add connection_tasks and client_tasks fields (TaskTracker); initialize them; include counts in MemoryDiagnostics; reorganize which background jobs use which tracker; add connection_tasks.abort_all() and message queue invalidation on reconnect.
Connection-scoped spawn migration
src/handlers/ib.rs, src/handlers/notification.rs, src/history_sync.rs, src/keepalive.rs, src/message.rs, src/receipt.rs, src/pair.rs, src/pdo.rs
Replace many runtime.spawn(...).detach() usages with connection_tasks.spawn(&*runtime, ...) (or client_tasks.spawn(...) where appropriate), removing explicit .detach() and routing lifecycle through TaskTracker.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant Client as Client
    participant Tracker as TaskTracker
    participant Runtime as Runtime
    participant Task as Background Task

    rect rgba(100,150,200,0.5)
    Note over App,Runtime: Old Flow — fire-and-forget
    App->>Runtime: spawn(future).detach()
    Runtime->>Task: run task independently
    end

    rect rgba(150,100,200,0.5)
    Note over App,Tracker: New Flow — TaskTracker-managed
    App->>Tracker: spawn(&runtime, future)
    Tracker->>Runtime: spawn(future)
    Runtime-->>Tracker: AbortHandle
    Tracker->>Tracker: track(handle)
    Runtime->>Task: start task
    App->>Tracker: abort_all() on teardown
    Tracker->>Task: abort via handles
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble bytes beneath the moon,

Handles clasped, no tasks marooned.
Tracker hops, queues tidy, neat,
Abort on drop — teardown complete.
A little hop, a stable beat.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor!: add TaskTracker for structured task lifecycle management' accurately describes the main change—introducing a TaskTracker mechanism for managing task lifecycles.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/task-tracker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 28, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 1m 38s —— View job


Code Review: TaskTracker for structured task lifecycle management

  • Read core TaskTracker implementation (wacore/src/runtime.rs)
  • Read client changes (src/client.rs)
  • Read persistence changes (src/store/persistence_manager.rs, wacore/src/store/persistence.rs)
  • Read handler/feature changes (bot, ib, notification, history_sync, keepalive, message, pair, pdo, receipt)
  • Provide review feedback

Overall this is a clean, well-scoped refactor. The two-tier tracker model (connection vs client lifetime) is the right design, the #[must_use] on AbortHandle is a great compile-time safety net, and the message_queues.invalidate_all() fix is a real bug fix. A few observations:

Handle vec growth in connection_tasks

TaskTracker appends to a Vec<AbortHandle> on every track() call but only clears it on abort_all(). For connection_tasks, that means the vec accumulates handles for every spawned task across the entire connection lifetime — node processing tasks (process_decrypted_node), retry receipts, PDO requests, custom enc handlers, etc. Many of these are short-lived tasks that complete quickly, but their (now-inert) AbortHandles stay in the vec until disconnect.

For a typical session this is probably fine (handles are small — just a Mutex<Option<Box<dyn FnOnce()>>>), but for long-lived connections processing many messages, this could grow to thousands of entries. Worth considering:

  • A periodic sweep that drops handles whose abort_fn is None (already consumed or completed)
  • Or tracking whether the inner fn has been consumed and filtering on track()

Not a blocker, but something to monitor via the MemoryDiagnostics you've already wired up.

abort_all() ordering in cleanup_connection_state()

The placement looks correct — connection_tasks.abort_all() fires before shutdown_notifier.notify() and before the transport is torn down. The comment about "belt-and-suspenders" for the shutdown_notifier is accurate and good to keep.

One subtle point: abort_all() takes the vec by value and iterates, calling abort() on each handle. If any abort function panics (e.g., a poisoned mutex inside a Tokio abort handle), it would prevent the remaining handles from being aborted. A catch_unwind or at least iterating with explicit drop order could harden this, but in practice Tokio's JoinHandle::abort() doesn't panic, so this is theoretical.

run_background_saver — nice API change

Returning AbortHandle instead of detaching internally is the right call. The caller (bot.rs:691) tracks it on client_tasks, which means the saver is properly aborted on client drop. The debug! log after creating the handle still fires correctly since it's before the return.

One note: since run_background_saver drops self (the Arc) and uses a Weak, the task already exits gracefully when the PersistenceManager is dropped. The AbortHandle tracking provides a second mechanism. This is fine — belt and suspenders — but the dual-exit paths could be documented with a one-line comment.

message_queues.invalidate_all() — good bug fix

This addresses a real issue where per-chat worker tasks would survive reconnects because their channel senders remained alive in the cache. Invalidating them on disconnect forces the workers to exit via channel close. Well placed after retried_group_messages.invalidate_all().

Minor nits

  1. TaskTracker missing Send + Sync assertion — It uses std::sync::Mutex so it should be Send + Sync, but adding a static assert (const _: () = { fn assert_send_sync<T: Send + Sync>() {} assert_send_sync::<TaskTracker>(); };) would catch regressions if the internals ever change.

  2. len() docstring accuracy — The doc says "includes already-completed tasks" which is accurate but could mislead users of MemoryDiagnostics into thinking the count reflects active tasks. Consider renaming to tracked_count() or adding a note in the diagnostics display.

  3. bot.rs accesses client_tasks after Client::newclient.client_tasks.track(bg_saver_handle) at line 691 works because client_tasks is pub(crate), but it means the Client is briefly alive without its background saver tracked. If anything between Client::new() and this line panics, the saver handle is dropped and the task aborts. This is a narrow window and probably fine, but passing the handle into Client::new() would close it.

Verdict

This is a solid improvement — it turns an implicit contract ("remember to detach or you'll abort your task") into an explicit one enforced by the compiler. The two-tier model is clean and the migration is thorough across all 18+ spawn sites. The message_queues fix alone justifies the PR.

Looks good to merge with the vec growth consideration as a follow-up item.


@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9c9ed22c6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/runtime.rs
rt: &dyn Runtime,
future: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
) {
self.track(rt.spawn(future));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prune completed handles from TaskTracker

TaskTracker::spawn stores every returned AbortHandle, but nothing removes handles when the task finishes, so completed tasks remain in handles until disconnect/drop. In this commit many high-frequency paths now use connection_tasks.spawn (including per-stanza processing), so a long-lived connection will accumulate an unbounded number of finished JoinHandles, increasing memory usage over time and making abort_all() progressively more expensive. Please remove tracked handles on task completion (or avoid tracking short-lived per-message tasks).

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Mar 28, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchrefactor/task-tracker
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()Instructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
47.13 x 1e3
(+9.25%)Baseline: 43.14 x 1e3
45.30 x 1e3
(104.05%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-5.33%)Baseline: 6,545.78
6,873.07
(90.16%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-27.13%)Baseline: 719,546.37
755,523.69
(69.40%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-5.68%)Baseline: 22,124.96
23,231.21
(89.83%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-16.75%)Baseline: 117,954.24
123,851.96
(79.29%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-10.14%)Baseline: 109,308.70
114,774.13
(85.59%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.11%)Baseline: 533,525.16
560,201.42
(95.14%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-4.72%)Baseline: 16,656.21
17,489.02
(90.74%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-8.00%)Baseline: 15,995,177.00
16,794,935.85
(87.62%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-20.62%)Baseline: 149,101.51
156,556.58
(75.60%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.11%)Baseline: 534,946.01
561,693.31
(95.14%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-4.21%)Baseline: 18,707.51
19,642.89
(91.22%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-21.65%)Baseline: 35,819,807.86
37,610,798.25
(74.62%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.11%)Baseline: 533,964.16
560,662.37
(95.14%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-7.39%)Baseline: 17,106.71
17,962.04
(88.20%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-8.00%)Baseline: 15,996,297.66
16,796,112.54
(87.62%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-13.30%)Baseline: 124,503.93
130,729.13
(82.57%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-10.13%)Baseline: 109,380.70
114,849.73
(85.59%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.36%)Baseline: 96,130.14
100,936.65
(90.13%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.45%)Baseline: 7,641.89
8,023.98
(91.95%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-1.79%)Baseline: 92,668.21
97,301.62
(93.53%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.36%)Baseline: 7,374.28
7,743.00
(95.58%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.53%)Baseline: 108,453.21
113,875.87
(93.78%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.30%)Baseline: 8,886.28
9,330.60
(95.52%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-7.98%)Baseline: 45,628.71
47,910.15
(87.64%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-3.90%)Baseline: 2,827.23
2,968.59
(91.52%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+2.28%)Baseline: 543,719.20
570,905.16
(97.41%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.30%)Baseline: 773.31
811.98
(94.95%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,668,103.00
(-0.12%)Baseline: 27,701,726.76
29,086,813.10
(95.12%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,828.00
(-0.06%)Baseline: 5,547,903.00
5,825,298.15
(95.19%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,061.00
(-1.36%)Baseline: 177,467.87
186,341.26
(93.95%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,710.00
(-1.42%)Baseline: 178,246.54
187,158.86
(93.88%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,350,981.00
(+0.41%)Baseline: 17,280,753.97
18,144,791.66
(95.63%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,353.00
(+0.54%)Baseline: 296,741.63
311,578.71
(95.76%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,542,938.00
(-0.42%)Baseline: 12,595,545.45
13,225,322.73
(94.84%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.39%)Baseline: 716,817.62
752,658.50
(95.61%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,129.00
(+9.25%)Baseline: 43,139.39
45,296.36
(104.05%)

libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,762.80
16,339,850.94
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,378,778.00
(-1.86%)Baseline: 5,480,966.30
5,755,014.62
(93.46%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
312,188.00
(-61.93%)Baseline: 820,102.75
861,107.89
(36.25%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.18%)Baseline: 2,825,373.94
2,966,642.64
(95.41%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.54%)Baseline: 3,471,537.08
3,645,113.93
(94.73%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
126,107,058.00
(+0.60%)Baseline: 125,350,801.33
131,618,341.39
(95.81%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.55%)Baseline: 11,818.60
12,409.53
(96.72%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.52%)Baseline: 3,833.32
4,024.99
(97.64%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.81%)Baseline: 87,783.49
92,172.66
(94.47%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-1.00%)Baseline: 79,820.49
83,811.52
(94.29%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.09%)Baseline: 50,929.94
53,476.43
(94.20%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.23%)Baseline: 5,769.46
6,057.93
(98.32%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,227.00
(+4.61%)Baseline: 2,128.93
2,235.38
(99.63%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.01%)Baseline: 21,918.02
23,013.92
(95.25%)
🐰 View full continuous benchmarking report in Bencher

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bot.rs`:
- Around line 653-655: The background saver started by
persistence_manager.run_background_saver (bg_saver_handle) is currently tracked
by client_tasks and will be aborted on Client drop (TaskTracker::Drop →
abort_all), risking loss of dirty state; modify the shutdown flow to perform an
explicit flush and graceful shutdown: add an async shutdown/stop method on Bot
or Client that calls persistence_manager.flush().await and then awaits the
bg_saver_handle to finish (or signals it to exit cleanly) before dropping
TaskTracker, and update places that previously dropped Client to call this
shutdown method; alternatively, if you cannot add an async Drop, document the
limitation and provide a public async shutdown API that users must call to
guarantee persistence.

In `@src/history_sync.rs`:
- Around line 183-188: The parse task started with connection_tasks.spawn
(Box::pin(async move { blocking_fut.await; })) is tied to the connection
lifecycle and can be aborted by cleanup_connection_state() via
connection_tasks.abort_all(), causing lost work after an acknowledgement; move
the parsing work out of the connection-scoped task manager or persist resume
state before sending the receipt. Concretely, either (a) submit blocking_fut to
a global/non-connection-scoped executor/runtime (use the global runtime or a
dedicated parser_tasks manager) instead of connection_tasks.spawn so it survives
disconnects, or (b) persist a parse job record (including blob id and progress)
before sending the receipt and have cleanup_connection_state()/connection
startup reconcile and resume parsing on reconnect; update calls around
connection_tasks.spawn, cleanup_connection_state, and where the receipt is sent
to reflect the chosen approach.

In `@src/pair.rs`:
- Around line 294-299: The spawned task using client.connection_tasks.spawn
currently runs client_for_unified.send_unified_session() which may be aborted by
the 515 stream error; change this so send_unified_session() is guaranteed to
complete — either await client_for_unified.send_unified_session().await directly
here (instead of spawning), or move the spawn into a long-lived task registry
(e.g., client.client_tasks) so the send_unified_session() future isn't cancelled
by connection_tasks shutdown; update the call sites around
client.connection_tasks.spawn, client.runtime and
client_for_unified.send_unified_session() accordingly to ensure the future is
tracked to completion.

In `@wacore/src/runtime.rs`:
- Around line 115-167: TaskTracker currently accumulates AbortHandle entries
forever; update it to prune handles for tasks that have already completed to
avoid unbounded growth. Modify TaskTracker::track to prune stale handles before
pushing the new AbortHandle (e.g., call self.handles.lock()... and Vec::retain
to keep only active handles), also do the same trim in len() (and optionally at
the start of abort_all()) so reported length excludes completed tasks; reference
the TaskTracker struct and the methods track, len, and abort_all when making
these changes (use AbortHandle's appropriate status/API such as is_aborted() or
equivalent to detect completed handles).
🪄 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: ASSERTIVE

Plan: Pro

Run ID: 7c164903-1b26-427f-9f8c-f8ac0203f764

📥 Commits

Reviewing files that changed from the base of the PR and between 336b189 and a9c9ed2.

📒 Files selected for processing (13)
  • src/bot.rs
  • src/client.rs
  • src/handlers/ib.rs
  • src/handlers/notification.rs
  • src/history_sync.rs
  • src/keepalive.rs
  • src/message.rs
  • src/pair.rs
  • src/pdo.rs
  • src/receipt.rs
  • src/store/persistence_manager.rs
  • wacore/src/runtime.rs
  • wacore/src/store/persistence.rs

Comment thread src/bot.rs
Comment on lines +653 to 655
let bg_saver_handle = persistence_manager
.clone()
.run_background_saver(runtime.clone(), std::time::Duration::from_secs(30));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential data loss: background saver aborted without final flush on Client drop.

Tracking the background saver via client_tasks means it will be aborted when Client is dropped (via TaskTracker::Dropabort_all()). If there's dirty state that hasn't been flushed yet, it will be lost.

The persistence module comment (lines 136-140 in wacore/src/store/persistence.rs) acknowledges this limitation but doesn't address it. Consider either:

  1. Adding an explicit flush() call before Client drop (requires adding a shutdown method)
  2. Calling persistence_manager.flush().await in Bot::drop or similar cleanup path
  3. Documenting this as a known limitation with guidance for users who need guaranteed persistence

This is a behavioral change from the previous fire-and-forget approach where the saver could complete naturally.

Also applies to: 690-691

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bot.rs` around lines 653 - 655, The background saver started by
persistence_manager.run_background_saver (bg_saver_handle) is currently tracked
by client_tasks and will be aborted on Client drop (TaskTracker::Drop →
abort_all), risking loss of dirty state; modify the shutdown flow to perform an
explicit flush and graceful shutdown: add an async shutdown/stop method on Bot
or Client that calls persistence_manager.flush().await and then awaits the
bg_saver_handle to finish (or signals it to exit cleanly) before dropping
TaskTracker, and update places that previously dropped Client to call this
shutdown method; alternatively, if you cannot add an async Drop, document the
limitation and provide a public async shutdown API that users must call to
guarantee persistence.

Comment thread src/history_sync.rs
Comment thread src/pair.rs
Comment thread wacore/src/runtime.rs
Comment on lines +115 to +167
/// Tracks spawned tasks and aborts them all on demand or when dropped.
///
/// Two intended scopes:
/// - **Connection-scoped**: aborted on disconnect via `abort_all()`.
/// - **Client-scoped**: aborted when the owning struct is dropped.
pub struct TaskTracker {
handles: std::sync::Mutex<Vec<AbortHandle>>,
}

impl TaskTracker {
pub fn new() -> Self {
Self {
handles: std::sync::Mutex::new(Vec::new()),
}
}

/// Store an existing `AbortHandle` so it will be aborted on `abort_all()` / drop.
pub fn track(&self, handle: AbortHandle) {
self.handles
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(handle);
}

/// Abort every tracked task and clear the list.
pub fn abort_all(&self) {
let handles = std::mem::take(&mut *self.handles.lock().unwrap_or_else(|p| p.into_inner()));
for handle in handles {
handle.abort();
}
}

/// Number of tracked handles (includes already-completed tasks).
pub fn len(&self) -> usize {
self.handles.lock().unwrap_or_else(|p| p.into_inner()).len()
}

pub fn is_empty(&self) -> bool {
self.len() == 0
}
}

impl Default for TaskTracker {
fn default() -> Self {
Self::new()
}
}

impl Drop for TaskTracker {
fn drop(&mut self) {
self.abort_all();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider pruning completed handles to prevent unbounded memory growth.

TaskTracker accumulates handles indefinitely since there's no mechanism to remove handles for tasks that have already completed. The len() documentation acknowledges this: "includes already-completed tasks."

For connection_tasks this is fine since abort_all() is called on disconnect. However, for long-lived client_tasks, if many short-lived tasks are tracked over hours/days, the Vec<AbortHandle> could grow unbounded.

Given the current usage pattern (only 3 client-scoped tasks per PR summary), this is unlikely to be a practical issue, but worth documenting or addressing in a future iteration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/runtime.rs` around lines 115 - 167, TaskTracker currently
accumulates AbortHandle entries forever; update it to prune handles for tasks
that have already completed to avoid unbounded growth. Modify TaskTracker::track
to prune stale handles before pushing the new AbortHandle (e.g., call
self.handles.lock()... and Vec::retain to keep only active handles), also do the
same trim in len() (and optionally at the start of abort_all()) so reported
length excludes completed tasks; reference the TaskTracker struct and the
methods track, len, and abort_all when making these changes (use AbortHandle's
appropriate status/API such as is_aborted() or equivalent to detect completed
handles).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
src/receipt.rs (1)

70-84: ⚠️ Potential issue | 🟠 Major

Do not make retry handling abortable until it is cancellation-safe.

connection_tasks.abort_all() can now cut this future at any .await, but handle_retry_receipt() in src/retry.rs writes retry-dedupe state and mutates Signal/session persistence in multiple steps before it finishes. That means a disconnect can record a retry as “handled” without ever completing the resend or the cleanup. Keep this path detached for now, or refactor handle_retry_receipt() into an idempotent/transactional flow before tracking it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/receipt.rs` around lines 70 - 84, The retry handling task is currently
spawned via connection_tasks.spawn and thus can be aborted by
connection_tasks.abort_all(), which may cut off handle_retry_receipt (in
src/retry.rs) mid-mutation and leave retry/session state inconsistent; instead
run the retry work in a detached/non-abortable task (e.g. spawn on the runtime
directly rather than using connection_tasks.spawn) so
handle_retry_receipt(&receipt, &node_clone) runs to completion, or alternatively
refactor handle_retry_receipt into an idempotent/transactional flow before
reintroducing abortable execution; update the spawn call that references
connection_tasks.spawn and client_clone.handle_retry_receipt to use a detached
runtime spawn (or equivalent non-abortable mechanism) and/or make
handle_retry_receipt cancellation-safe.
src/pdo.rs (1)

441-460: ⚠️ Potential issue | 🟠 Major

Cancellation now leaves stale PDO dedupe entries behind.

This future is now abortable on disconnect, but send_pdo_placeholder_resend_request() inserts into pdo_pending_requests before it reaches the send/error-cleanup path. If teardown cancels after that insert, the stale cache key suppresses the next PDO attempt for the same message on reconnect. Either make that pending-entry lifecycle cancellation-safe or keep this request detached until it is on the wire.

src/pair.rs (1)

67-105: ⚠️ Potential issue | 🟡 Minor

Clear stale QR cancellation state on disconnect.

With the QR rotator now living in connection_tasks, cleanup_connection_state() aborts it before transport teardown. That drops stop_rx, but pairing_cancellation_tx stays populated here until some later pair-success or new pair-device happens, so pairing state can survive a reconnect with no live task behind it. Clear the option during connection cleanup, or before installing a new sender, to keep reconnect state consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 67 - 105, cleanup_connection_state() currently
aborts the QR rotator task but leaves client.pairing_cancellation_tx populated,
allowing stale pairing state to persist across reconnects; fix by clearing that
Option by locking client.pairing_cancellation_tx and setting it to None during
cleanup_connection_state() (and/or just before installing a new sender where
stop_tx is assigned) so there is never a hanging sender handle left
behind—update the code paths that install stop_tx (where
*client.pairing_cancellation_tx.lock().await = Some(stop_tx) is called) and the
cleanup_connection_state() function to ensure they explicitly set the Option to
None when aborting or replacing the rotator.
src/message.rs (2)

437-450: ⚠️ Potential issue | 🟠 Major

Don't connection-track per-enc custom handlers.

This branch runs once per incoming enc node. Putting it in connection_tasks reintroduces the same unbounded per-stanza handle growth the PR already had to back out elsewhere, and abort_all() can now drop already-received custom messages mid-handler during reconnect. These handlers should stay detached, or move to a tracker that removes completed tasks.

Suggested direction
-                self.connection_tasks.spawn(
-                    &*self.runtime,
-                    Box::pin(async move {
-                        if let Err(e) = handler_clone
-                            .handle(client_clone, &enc_node_clone, &info_arc)
-                            .await
-                        {
-                            log::warn!(
-                                "Custom handler for enc type '{}' failed: {e:?}",
-                                enc_type_owned
-                            );
-                        }
-                    }),
-                );
+                self.runtime
+                    .spawn(Box::pin(async move {
+                        if let Err(e) = handler_clone
+                            .handle(client_clone, &enc_node_clone, &info_arc)
+                            .await
+                        {
+                            log::warn!(
+                                "Custom handler for enc type '{}' failed: {e:?}",
+                                enc_type_owned
+                            );
+                        }
+                    }))
+                    .detach();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 437 - 450, The current code spawns per-enc
custom handler tasks into self.connection_tasks (via connection_tasks.spawn with
runtime and Box::pin async) which causes unbounded per-stanza task tracking and
allows abort_all() to cancel handlers; change this to run handlers detached
instead of tracking them: replace the use of self.connection_tasks.spawn(...)
that wraps handler_clone.handle(client_clone, &enc_node_clone, &info_arc).await
with a detached spawn (e.g., tokio::spawn) or a dedicated tracker that removes
handles when they complete, so custom handlers are not kept in connection_tasks
and are not aborted by abort_all(); keep references to handler_clone,
client_clone, enc_node_clone, and info_arc as before.

195-250: ⚠️ Potential issue | 🟠 Major

Keep retry receipts out of connection_tasks.

Line 201 increments the retry counter before any send occurs. Once this best-effort receipt is tracked, cleanup_connection_state().abort_all() can cancel it after the count bump but before send_retry_receipt(), so reconnects burn retry numbers without ever transmitting a retry. This is also a high-volume error path, so retaining one AbortHandle per failed message will grow connection_tasks across long recovery sessions.

Suggested direction
-        self.connection_tasks.spawn(&*self.runtime, Box::pin(async move {
+        self.runtime.spawn(Box::pin(async move {
             let cache_key = client
                 .make_retry_cache_key(&info.source.chat, &info.id, &info.source.sender)
                 .await;
@@
             if retry_count == 1 {
                 client.spawn_pdo_request(&info);
             }
-        }));
+        }))
+        .detach();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 195 - 250, The retry-count bump and send logic
should not be scheduled on connection_tasks because
cleanup_connection_state().abort_all() can cancel the task after
increment_retry_count() but before send_retry_receipt(), burning retry counters
and leaking AbortHandles; move the entire async block currently spawned via
self.connection_tasks.spawn(...) to a non-abortable executor (e.g. spawn on the
runtime directly with self.runtime.spawn or tokio::spawn) or otherwise run
increment/send on a short-lived non-connection task so increment_retry_count(),
send_retry_receipt(), and the conditional
spawn_pdo_request()/spawn_pdo_request_with_options() execute even if
connection_tasks are aborted; preserve existing calls to increment_retry_count,
send_retry_receipt, spawn_pdo_request, spawn_pdo_request_with_options, and the
HIGH_RETRY_COUNT_THRESHOLD logic when relocating the block.
src/client.rs (1)

1900-1946: 🧹 Nitpick | 🔵 Trivial

Note on TaskTracker memory management.

The TaskTracker accumulates handles for completed tasks until abort_all() is called. This is acceptable for connection_tasks since abort_all() clears the Vec on disconnect. However, for very long-lived connections with frequent spawns, handles would accumulate.

The current spawn sites in handle_success are bounded per-connection (background init, app state sync), and push name updates are infrequent. The intentional detach() for per-stanza processing (line 1311) correctly avoids unbounded growth for high-frequency spawns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1900 - 1946, The TaskTracker used by
connection_tasks can accumulate completed task handles for long-lived
connections; make high-frequency/fire-and-forget spawns call detach() instead of
storing their handle in connection_tasks and reserve connection_tasks only for
bounded-per-connection work (background init, app state sync) that you already
call abort_all() for on disconnect; update the spawn sites in handle_success
(replace storing handles for per-stanza or frequent tasks with detach()) and, if
there are other frequent spawn sites, likewise switch them to detach() or call a
pruning method after spawn so completed handles are removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/runtime.rs`:
- Around line 132-137: track() currently only appends AbortHandle to
self.handles and never removes completed tasks, causing unbounded growth; change
track() (and the analogous code around the handles usage at the other location
referenced) to prune completed handles before/after pushing a new one: lock
self.handles, call .retain(...) to keep only active handles (for example, if you
store JoinHandle use .is_finished(), or if you must use AbortHandle wrap it so
you can detect completion or remove the handle when the spawned task finishes
via a cleanup callback), then push the new handle; ensure the same pruning logic
is applied where handles are modified so dead handles are not accumulated.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 1900-1946: The TaskTracker used by connection_tasks can accumulate
completed task handles for long-lived connections; make
high-frequency/fire-and-forget spawns call detach() instead of storing their
handle in connection_tasks and reserve connection_tasks only for
bounded-per-connection work (background init, app state sync) that you already
call abort_all() for on disconnect; update the spawn sites in handle_success
(replace storing handles for per-stanza or frequent tasks with detach()) and, if
there are other frequent spawn sites, likewise switch them to detach() or call a
pruning method after spawn so completed handles are removed.

In `@src/message.rs`:
- Around line 437-450: The current code spawns per-enc custom handler tasks into
self.connection_tasks (via connection_tasks.spawn with runtime and Box::pin
async) which causes unbounded per-stanza task tracking and allows abort_all() to
cancel handlers; change this to run handlers detached instead of tracking them:
replace the use of self.connection_tasks.spawn(...) that wraps
handler_clone.handle(client_clone, &enc_node_clone, &info_arc).await with a
detached spawn (e.g., tokio::spawn) or a dedicated tracker that removes handles
when they complete, so custom handlers are not kept in connection_tasks and are
not aborted by abort_all(); keep references to handler_clone, client_clone,
enc_node_clone, and info_arc as before.
- Around line 195-250: The retry-count bump and send logic should not be
scheduled on connection_tasks because cleanup_connection_state().abort_all() can
cancel the task after increment_retry_count() but before send_retry_receipt(),
burning retry counters and leaking AbortHandles; move the entire async block
currently spawned via self.connection_tasks.spawn(...) to a non-abortable
executor (e.g. spawn on the runtime directly with self.runtime.spawn or
tokio::spawn) or otherwise run increment/send on a short-lived non-connection
task so increment_retry_count(), send_retry_receipt(), and the conditional
spawn_pdo_request()/spawn_pdo_request_with_options() execute even if
connection_tasks are aborted; preserve existing calls to increment_retry_count,
send_retry_receipt, spawn_pdo_request, spawn_pdo_request_with_options, and the
HIGH_RETRY_COUNT_THRESHOLD logic when relocating the block.

In `@src/pair.rs`:
- Around line 67-105: cleanup_connection_state() currently aborts the QR rotator
task but leaves client.pairing_cancellation_tx populated, allowing stale pairing
state to persist across reconnects; fix by clearing that Option by locking
client.pairing_cancellation_tx and setting it to None during
cleanup_connection_state() (and/or just before installing a new sender where
stop_tx is assigned) so there is never a hanging sender handle left
behind—update the code paths that install stop_tx (where
*client.pairing_cancellation_tx.lock().await = Some(stop_tx) is called) and the
cleanup_connection_state() function to ensure they explicitly set the Option to
None when aborting or replacing the rotator.

In `@src/receipt.rs`:
- Around line 70-84: The retry handling task is currently spawned via
connection_tasks.spawn and thus can be aborted by connection_tasks.abort_all(),
which may cut off handle_retry_receipt (in src/retry.rs) mid-mutation and leave
retry/session state inconsistent; instead run the retry work in a
detached/non-abortable task (e.g. spawn on the runtime directly rather than
using connection_tasks.spawn) so handle_retry_receipt(&receipt, &node_clone)
runs to completion, or alternatively refactor handle_retry_receipt into an
idempotent/transactional flow before reintroducing abortable execution; update
the spawn call that references connection_tasks.spawn and
client_clone.handle_retry_receipt to use a detached runtime spawn (or equivalent
non-abortable mechanism) and/or make handle_retry_receipt cancellation-safe.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: 401b99cc-5225-43d6-9aaf-1382b172d293

📥 Commits

Reviewing files that changed from the base of the PR and between 336b189 and a12f04c.

📒 Files selected for processing (13)
  • src/bot.rs
  • src/client.rs
  • src/handlers/ib.rs
  • src/handlers/notification.rs
  • src/history_sync.rs
  • src/keepalive.rs
  • src/message.rs
  • src/pair.rs
  • src/pdo.rs
  • src/receipt.rs
  • src/store/persistence_manager.rs
  • wacore/src/runtime.rs
  • wacore/src/store/persistence.rs

Comment thread wacore/src/runtime.rs
Comment on lines +132 to +137
pub fn track(&self, handle: AbortHandle) {
self.handles
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(handle);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Completed tasks accumulate forever in TaskTracker.

track() only appends, and nothing removes a handle when its task finishes. With the new call sites in src/receipt.rs, src/pdo.rs, src/history_sync.rs, and the periodic cleanup in src/keepalive.rs, a long-lived connection will keep every dead handle until disconnect/drop. This needs completion pruning, or the tracker should be limited to truly long-lived loops.

Also applies to: 147-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/runtime.rs` around lines 132 - 137, track() currently only appends
AbortHandle to self.handles and never removes completed tasks, causing unbounded
growth; change track() (and the analogous code around the handles usage at the
other location referenced) to prune completed handles before/after pushing a new
one: lock self.handles, call .retain(...) to keep only active handles (for
example, if you store JoinHandle use .is_finished(), or if you must use
AbortHandle wrap it so you can detect completion or remove the handle when the
spawned task finishes via a cleanup callback), then push the new handle; ensure
the same pruning logic is applied where handles are modified so dead handles are
not accumulated.

Comment thread wacore/src/runtime.rs
Comment on lines +140 to +145
pub fn abort_all(&self) {
let handles = std::mem::take(&mut *self.handles.lock().unwrap_or_else(|p| p.into_inner()));
for handle in handles {
handle.abort();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Close the tracker before aborting the current batch.

Line 141 only drains the current Vec. A concurrent track()/spawn() right after that lands in the new empty Vec and survives the same disconnect that was supposed to cancel all connection-scoped work. For connection_tasks, that reintroduces the stale-task-on-reconnect race this PR is trying to remove. Add a closed/generation state so post-abort_all() handles are rejected or aborted until the next connection is initialized.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 28, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 16s —— View job


Code Review: TaskTracker for structured task lifecycle management

  • Read core TaskTracker implementation (wacore/src/runtime.rs)
  • Read client changes (src/client.rs)
  • Read persistence changes (src/store/persistence_manager.rs, wacore/src/store/persistence.rs)
  • Read handler/feature changes (bot, ib, notification, history_sync, keepalive, message, pair, pdo, receipt)
  • Analyze existing review comments and cross-reference with code
  • Provide review feedback

Good refactor overall. The three-commit evolution tells a clear story: initial implementation, then backing out per-stanza tracking (unbounded growth), then removing forceful abort_all() from disconnect (killed post-login tasks during 515 reconnect). The final state is sound.

Key observation for other reviewers

Most of the CodeRabbit/Codex comments about cancellation-safety (handle_retry_receipt mid-mutation, send_unified_session racing 515, history sync parsing loss, PDO dedupe staleness) are no longer applicable. Commit 96d0176 removed connection_tasks.abort_all() from cleanup_connection_state(). Neither connection_tasks nor client_tasks calls abort_all() at runtime — only TaskTracker::Drop fires when the Client Arc is fully dropped. So tracked tasks are never forcefully aborted during normal reconnect cycles.

What the TaskTracker actually provides now

Per commit 96d0176, its three purposes are:

  1. #[must_use] compile-time enforcement — forces every runtime.spawn() call site to explicitly choose .detach() or tracker registration
  2. Diagnosticsconnection_tasks.len() / client_tasks.len() in MemoryDiagnostics
  3. Client drop cleanupTaskTracker::Drop aborts everything when the Client Arc is fully released

Remaining concerns

1. Unbounded handle growth in connection_tasks (medium priority)

Since abort_all() is never called at runtime, connection_tasks accumulates AbortHandles for the entire lifetime of the Client, not just per-connection. Every retry receipt (src/receipt.rs:70), PDO request (src/pdo.rs:441), custom enc handler (src/message.rs:437), retry-count-bump (src/message.rs:195), keepalive cleanup (src/keepalive.rs:148), and history sync parse (src/history_sync.rs:183) appends a handle that is never removed until the Client is dropped.

For a long-lived client processing many messages, this could grow to tens of thousands of entries. Each AbortHandle is small (~48 bytes with the Mutex<Option<Box<...>>>), but the abort functions they hold close over Tokio JoinHandles which themselves retain metadata even after the task completes.

The second commit (a12f04c) already correctly identified this pattern for process_decrypted_node and reverted it to .detach(). The same reasoning applies to several other high-frequency paths that are still tracked:

  • src/message.rs:195 — retry count bump (once per undecryptable message)
  • src/message.rs:437 — custom enc handlers (once per enc node with custom handler)
  • src/receipt.rs:70 — retry receipt handling
  • src/pdo.rs:441 — PDO requests
  • src/keepalive.rs:148 — sent message cleanup (every keepalive cycle)

These are all fire-and-forget tasks that don't benefit from abort tracking since abort_all() is never called. They'd be better off as .detach() — same as process_decrypted_node was already reverted to.

Fix this →

2. connection_tasks vs client_tasks distinction is now blurred (low priority)

With abort_all() removed from disconnect, connection_tasks and client_tasks have identical lifecycle semantics — both only abort on Client drop. The separation is still useful as documentation of intent (which tasks are conceptually connection-scoped vs client-scoped), but the runtime behavior is the same. Worth noting in comments or docs.

3. TaskTracker::len() semantics for diagnostics (nit)

len() doc says "includes already-completed tasks" which is accurate but misleading when displayed in MemoryDiagnostics. Since handles are never pruned, connection_tasks.len() reflects total-ever-spawned rather than currently-active. Consider renaming to total_tracked() or adding a comment in the diagnostics display. If you add pruning for completed handles (point 1), this becomes more useful.

What looks good

  • #[must_use] on AbortHandle — excellent compile-time safety net that forces explicit intent at every spawn site
  • message_queues.invalidate_all() — real bug fix for stale per-chat workers surviving reconnects
  • run_background_saver returning AbortHandle — cleaner API, caller controls lifecycle
  • The commit-by-commit iteration — backing out the per-stanza tracking and forceful abort shows good judgment about what actually works in practice vs theory
  • 14 remaining .detach() calls are all correctly identified as intentional fire-and-forget

Verdict

The core design is solid. The main actionable item is reverting the high-frequency fire-and-forget paths back to .detach() (point 1 above) — they gain nothing from tracking since abort_all() is never called, and they'll cause unbounded growth. The bounded-per-connection tasks (keepalive loop, app state sync, QR rotation, prekey upload, dirty bits, background init) are fine to keep tracked.


@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 732-737: The spawned long-lived task captures a strong Arc<Client>
(via cleanup_arc) which prevents Client's Drop from ever running and leaks the
loop; change the task to capture a Weak<Client> (downgrading the Arc before
spawn) or extract only the minimal non-Client state required by
device_registry_cleanup_loop and move that into the closure so it does not hold
the Client; alternatively add an explicit shutdown path that calls
client_tasks.abort() (or otherwise stops TaskTracker) before the final
Arc<Client> is dropped so the closure cannot keep the Client alive; update
references around arc.client_tasks.spawn, arc.runtime, and
cleanup_arc.device_registry_cleanup_loop() accordingly.
- Around line 1114-1118: connection_tasks is never reset across reconnects so
TaskTracker accumulates AbortHandles and completed counts, leaking handles and
stale work into new sessions; in cleanup_connection_state() replace or swap the
existing connection_tasks TaskTracker with a fresh TaskTracker for each new
connection (or, if you prefer non-aborting teardown, implement a prune step that
removes completed handles and aborts or marks remaining ones as stale) and
update memory_diagnostics().connection_tasks to reflect the new tracker; ensure
all connection-scoped spawns reference the new tracker and still check
connection_generation and is_shutting_down() so no work from the old tracker
runs in the new session.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: c386a346-387c-479b-bba3-f1ec02f7beb3

📥 Commits

Reviewing files that changed from the base of the PR and between a12f04c and 96d0176.

📒 Files selected for processing (1)
  • src/client.rs

Comment thread src/client.rs
Comment thread src/client.rs
Comment on lines +1114 to +1118
// Tasks detect staleness cooperatively via connection_generation,
// is_shutting_down(), and this notifier. Forceful abort is intentionally
// NOT used here — tasks like the post-login sequence must survive 515
// reconnect cycles to dispatch Connected. TaskTracker::Drop handles
// final cleanup when the Client is dropped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

connection_tasks now survives every reconnect, which leaks handles and can leak stale work into the next session.

cleanup_connection_state() no longer aborts or replaces the tracker, but TaskTracker only accumulates AbortHandles and len() counts completed ones too. So the tracker grows monotonically across reconnects, memory_diagnostics().connection_tasks becomes misleading, and any connection-scoped spawn that misses a generation/shutdown guard can still run on the new connection. Please reset/swap the tracker per connection, or add a non-aborting prune step once teardown begins.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1114 - 1118, connection_tasks is never reset
across reconnects so TaskTracker accumulates AbortHandles and completed counts,
leaking handles and stale work into new sessions; in cleanup_connection_state()
replace or swap the existing connection_tasks TaskTracker with a fresh
TaskTracker for each new connection (or, if you prefer non-aborting teardown,
implement a prune step that removes completed handles and aborts or marks
remaining ones as stale) and update memory_diagnostics().connection_tasks to
reflect the new tracker; ensure all connection-scoped spawns reference the new
tracker and still check connection_generation and is_shutting_down() so no work
from the old tracker runs in the new session.

@jlucaso1 jlucaso1 changed the title refactor: add TaskTracker for structured task lifecycle management refactor!: add TaskTracker for structured task lifecycle management Mar 28, 2026
Replace ad-hoc .detach() calls with tracked task spawning so connection-
scoped tasks are aborted on disconnect and client-scoped tasks are
aborted when Client drops.

- Add TaskTracker to wacore::runtime (Vec<AbortHandle> + abort_all())
- Add #[must_use] to AbortHandle for compile-time enforcement
- Add connection_tasks / client_tasks fields to Client
- Abort connection_tasks in cleanup_connection_state()
- Invalidate message_queues on disconnect (bug fix: stale workers)
- Migrate 21 detach sites to tracked; keep 14 as intentional fire-and-forget
- run_background_saver now returns AbortHandle instead of detaching
…ncy)

Per-stanza processing is fire-and-forget — it runs for every non-inline
stanza and would accumulate unbounded handles in connection_tasks during
long connections. The message loop itself exits on disconnect, so
individual stanza tasks don't need abort tracking.
Tasks already detect staleness cooperatively via connection_generation,
is_shutting_down(), and shutdown_notifier. Forceful abort via abort_all()
killed the post-login task before it could dispatch Connected — the mock
server sends 515 immediately after auth on post-pairing reconnect.

TaskTracker now serves three purposes:
- #[must_use] compile-time enforcement at every spawn site
- Diagnostics (connection_tasks.len() / client_tasks.len())
- Client drop cleanup (TaskTracker::Drop aborts everything)
Prevents unbounded AbortHandle accumulation across reconnect cycles.
By the time connect() runs, the previous connection's cooperative tasks
have already received shutdown_notifier and the generation was bumped.
@jlucaso1
jlucaso1 force-pushed the refactor/task-tracker branch from 8264b32 to 8536c78 Compare March 28, 2026 22:14
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

Superseded by a minimal version — the TaskTracker approach was overengineered. The cooperative cancellation via connection_generation/shutdown_notifier already handles task lifecycle correctly.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8536c78e93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message.rs
Comment on lines +437 to +439
self.connection_tasks.spawn(
&*self.runtime,
Box::pin(async move {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep custom enc handler tasks outside connection aborts

handle_incoming_message now schedules custom EncHandler::handle work on connection_tasks, but connect() forcibly cancels that tracker via connection_tasks.abort_all() at the beginning of each reconnect cycle. Because this branch immediately continues after spawning the handler, a reconnect during handler execution can silently drop custom message processing with no retry path. This is user-visible for any handler that performs awaited work (DB/network) and runs longer than the reconnect gap.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 deleted the refactor/task-tracker branch April 7, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant