fix(matrix-client): free replaced matrix-sdk clients #158
No reviewers
Labels
No labels
blocked
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
waiting-on-julian
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jlxq0/matrix-mcp!158
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/memory-leak-20261102"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Root cause
matrix-sdk (0.19.1
client/mod.rs:3630, and 0.17 the same) buildsLatestEventswithSendQueue::new(self.clone()). That is a strongClient, and it is moved intolisten_to_updates_task. The task's abort handle is stored inClientInner::latest_events, so the task is only aborted when the veryClientit keeps alive is dropped. It only ends on its own when one of its three broadcast channels closes, and all three belong to that same client. The SDK initialisesLatestEventsinsideprocess_syncwhenever the event cache is subscribed. We subscribe it forlatest_event(#64, May 2026).So every client that completed a single sync was a reference cycle. It was never freed after a token change or an auth-expiry eviction replaced it. What stayed behind each time: its state, crypto and event-cache stores with their open SQLite pools, every room, and every event it had synced (the event cache never shrinks a room that has no subscriber). Production replaces a client each time the MAS access token expires (6 builds and 6
permanent auth failureexits in the 6 h of logs).Evidence
Measured in-process against a wiremock homeserver. Heap is glibc
mallinfo2(in use + mmapped). A witnessArccaptured by an event handler thatClientInnerowns shows whetherClientInnerwas freed.ClientInnernever freed, 24-33 store fds stay open after evictSo the harness leaks about 7.7 MiB per client at 50 rooms and 1,500 events. To make 250 MiB/day at about 24 replacements a day, each leaked client needs about 10 MiB. A larger account with an hour of traffic fits that figure. This is an inference: I did not measure a production client.
A first version of the experiment had a mock that returned 404 for
/versions. With that mock, no client ever reached/syncand every client was freed. The leak only shows up once the mock serves/versionsand a key upload. AGENTS.md now records this.Fix
Each cached client initialises
LatestEventsitself, before its sync loop starts, on a one-worker tokio runtime that the entry owns (LatestEventsRuntime). The runtime is shut down (shutdown_background) when the entry is dropped. Tokio then drops the runtime's tasks, which breaks the cycle.latest_eventbehaviour is unchanged. The cost is one extra worker thread per live client.Other candidates, quantified (not the cause, not changed)
rate_limitper-bearer buckets (bearer_read,bearer_write, initializebearer) are never pruned. That is about 250 B per entry per map, so about 750 B per distinct bearer. Even at one bearer every 5 min this is about 0.2 MiB/day.masintrospection cache: soft cap of 256 with a sweep of expired entries, TTL ≤ 60 s. Bounded.last_used: capped at 1024 entries.build_locksandretiring: one small entry perClientKey. AWeak<Client>pins only the 24-byte outer allocation, notClientInner.session.rsworkers: removed onclose_sessionand reaped when the worker transport drops.channelPeerMap, PendingMap and ConfigCache: keyed per route, with TTL and cap where it matters. Bounded by the number of routes.Remaining growth to know about (not fixed here)
While a client is alive, its event cache keeps every synced event in memory. LatestEvents listens to generic updates, not through a
RoomEventCacheSubscriber, so auto-shrink never fires. The harness shows about 1.9 KB per 200-byte text event. A client rebuilt hourly stays bounded. A long-lived client, such as a/channelsession with a non-expiring token, grows by that amount per event it receives. Bounding this means periodically subscribing to and dropping each room's event cache, which triggers the SDK's own shrink. That is a separate decision.SQLite
SQLITE_IOERR_GETTEMPPATH(6410)The app cannot fix this cleanly in-process.
matrix-sdk-sqliteopens its own connections and exposes no pragma hook, so there is notemp_store = MEMORY. SettingSQLITE_TMPDIRfrom Rust needsstd::env::set_var(unsafe in edition 2024) or thesqlite3_temp_directoryFFI, and the crate hasunsafe_code = "forbid". The deployment-sideemptyDiron/tmpis the right fix. A no-code alternative is the env varSQLITE_TMPDIR=<store dir>, which puts temp files on the PVC.Second commit: RUSTSEC-2026-0433
The first CI run passed fmt, clippy and all 393 tests, then failed
cargo audit. The cause was RUSTSEC-2026-0433 (pulldown-cmark0.13.5, quadratic wikilink parsing, high), which reached the advisory database today. The lockfile had not changed, somainis affected too.cargo update -p pulldown-cmark --precise 0.13.6changes only the lockfile. The package is transitive viaruma-eventsmarkdown support and 0.13.6 declares rust-version 1.71.1. All gates were re-run after the bump.Verification
cargo +1.98.0 fmt --all --check: cleancargo +1.98.0 clippy --all-targets --all-features --locked -- -D warnings: cleanRUSTFLAGS=-Dwarnings cargo +1.98.0 test --all-features --locked: 387 + 6 passedcargo +1.96.0 check --all-targets --all-features --locked: ok (build floor)cargo audit: 0 vulnerabilities, with the two existing allowed unmaintained notices.cargo deny check bans licenses sources: okLatestEventsRuntime::startcall, or keeping the runtime alive (mem::forget), each makea_replaced_client_that_has_synced_is_freedfail with "generation 0 was replaced but its client was never freed".Not merged, tagged or deployed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01E2xQRZDxzCiJjLb9uAhP9j