Skip to content

fix(threads): close the backend thread timeline when its last view closes (#308) - #328

Merged
ZhangHanDong merged 3 commits into
mainfrom
fix/thread-timeline-lifecycle
Aug 30, 2026
Merged

fix(threads): close the backend thread timeline when its last view closes (#308)#328
ZhangHanDong merged 3 commits into
mainfrom
fix/thread-timeline-lifecycle

Conversation

@ZhangHanDong

Copy link
Copy Markdown

Fixes #308. Draft — manual scenarios not yet verified.

Problem

Every opened thread creates a focused Timeline + timeline_subscriber_handler task + channels in JoinedRoomDetails::thread_timelines; closing the tab / popping the view never removed them. Long desktop sessions retained one timeline + task per distinct thread ever opened.

Invariants (spec task-thread-timeline-lifecycle)

V(t) = drawn views of thread t;  L(t) = t live ∨ pending in the worker table
th-1  live ∩ pending = ∅ ; close(t) ⇒ ¬L(t)
th-2  V(t): 1 → 0 ⇒ CloseThreadTimeline ⇒ ¬L(t) ∧ subscriber task aborted
th-3  V(t) > 1 ∧ one view closes ⇒ nothing is closed   (mobile deep nav)
th-4  ¬L(t) then open ⇒ fresh timeline
th-5  close(t) while building ⇒ the finished build is rejected & dropped
th-6  leave / kick / logout still clear everything (unchanged)

Changes

  • sliding_sync: generic ThreadTimelineTable<T> (begin_create / finish_create / fail_create / close) replaces the two fields; MatrixRequest::CloseThreadTimeline; finish_create rejection path logs "closed while building"; thread_timeline_counts(); live/pending counts in logs.
  • room_screen/state: TimelineStateCache<S> with per-thread invalidate/take/store; close_thread_timeline(cx, kind).
  • room_screen/thread_lifecycle (new): pure close_if_unreferenced.
  • main_desktop_ui close_tab/close_all_tabs, app mobile Pop wired.

Verification

Kind Tests Result
Table (th-1/th-5) 3 examples + prop_thread_table_invariants vs. reference model
UI decision (th-2/th-3) 3 examples + prop_close_fires_iff_last_reference_removed
Cache invalidation (th-4) invalidated_timeline_state_is_dropped_and_fresh_state_is_stored
Manual (th-2/3/4/5/6) desktop close / reopen / stress / mobile deep-nav / leave room / close-while-building

cargo test --lib: 690 green. agent-spec lifecycle: 10 passed / 0 failed / 6 manual.

🤖 Generated with Claude Code

ZhangHanDong and others added 3 commits August 19, 2026 02:19
…oses (#308)

Opening a thread created a focused Timeline, a subscriber task and channels in
`JoinedRoomDetails::thread_timelines`, but closing the tab (desktop) or popping
the view (mobile) never removed them, so a long session retained one timeline
+ task per distinct thread ever opened.

- sliding_sync: `ThreadTimelineTable<T>` replaces the `thread_timelines` +
  `pending_thread_timelines` pair and keeps `live ∩ pending = ∅`; `close()`
  forgets a pending build so a later `finish_create()` is rejected and the
  built timeline is dropped instead of leaking (Rule th-5). New
  `MatrixRequest::CloseThreadTimeline` drops the entry, which aborts its
  subscriber task via `PerTimelineDetails::drop`. `thread_timeline_counts()`
  and live/pending counts in the create/close logs for observability.
- room_screen/state: `TIMELINE_STATES` becomes `TimelineStateCache<S>` with
  per-kind `invalidate` (drop the cached state now, or mark it so a shown
  RoomScreen's late save is dropped) and `take` (clears the mark), plus
  `close_thread_timeline(cx, kind)`.
- room_screen/thread_lifecycle: pure `close_if_unreferenced(closed, remaining)`
  decides "last view gone?" so mobile deep navigation (room → thread → room →
  thread) never closes a thread still on the stack (Rule th-3).
- main_desktop_ui `close_tab` / `close_all_tabs` and app `StackNavigation Pop`
  call it; desktop ignores the undrawn mobile stack.

Spec: specs/task-thread-timeline-lifecycle.spec.md — 9 automated scenarios
(3 property tests) pass; 6 manual scenarios pending user verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…race

Review finding on #328: `pending` was keyed by event id only, so
begin(A) → close(A) → begin(A) let the *old* build's finish_create consume
the *new* attempt's pending slot (registering a timeline that was closed
semantically) and its fail_create cancel the new attempt.

`begin_create` now returns a `CreateToken { id, generation }` from a
monotonic counter; `finish_create(&token, ..)` / `fail_create(&token)` act
only if the token is the current pending generation for that id; `close`
still forgets any generation. The create task carries its token.

Tests: deterministic `thread_table_stale_generation_cannot_hijack_reopened_thread`
plus the property model now tracks generations and lets ops replay stale
tokens; both catch the id-only mutant. Spec th-5 updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ZhangHanDong
ZhangHanDong marked this pull request as ready for review August 30, 2026 06:53
@ZhangHanDong
ZhangHanDong merged commit e28e118 into main Aug 30, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Closed thread tabs retain timelines and subscriber tasks

1 participant