Skip to content

fix(ui): clear session caches at the identity boundary and give the wallet view a loader - #251

Merged
argszero merged 1 commit into
mainfrom
fix/session-cache-identity-leak
Sep 14, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/session-cache-identity-leak

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

Live in ui/js/app.js is a per-session cache, but nothing ever cleared it when the identity changed — and renderView's wallet branch was the only one without a loader. So after Alice used the dashboard and logged out, Bob opening the wallet saw Alice's Live.wallet.balance rendered into #wallet-forever ("永久点数" / permanent points) — and it never self-healed, because that view never fetched anything itself.

This PR fixes both halves at once.

Related Issue

None (no matching issue; found by inspection + a jsdom instrument).

Changes

Two halves, both required (removing either leaves a red face):

  1. Identity boundaries drop every cache slot. New resetSessionCaches() derives the slot names from the Live object literal itself (Object.keys(Live)), so a newly added slot is covered automatically; a hand-written roster is rejected by the gate. It is called at both identity boundaries:
    • session endexitGuest() (logout button + __atpLogout on 401);
    • session establishment — the top of loadSession() (boot and login both go through restoreSession()).
  2. The wallet view gets its own loader. renderView("wallet") becomes renderWallet(); if (loggedIn()) loadWallet();, with loadWallet() mirroring the tail of loadDashboard() (refreshWallet()renderWallet()).

Half 2 also fixes a single-session display bug: logging in and going straight to the wallet left Live.wallet null forever, so the cell fell back to D.USER.balance — which is available (balance + gift_balance, src/routes/wallet.rs), i.e. permanent points plus today's gift shown as if it were the permanent balance.

No new i18n keys; ui/index.html cache-bust bumped to app.js?v=20260915-5.

  • 功能/修复说明
  • 涉及配置/数据结构的改动已同步示例文件(无配置/数据结构改动)

Tests

  • cargo test 全部通过 — 271 passed (was 269; +2 new gate tests)
  • cargo fmt --check 通过
  • 新增/更新了单元测试(如适用)

Static gate — src/state_gate.rs (test-only, zero new deps)

Two derived invariants added:

  • the_identity_boundaries_drop_every_session_cache — the reset must be derived (contains Object.keys(Live)), must not contain a per-slot assignment (a second roster would rot), and must be called from both loadSession and exitGuest.
  • the_view_router_renders_and_loads_in_every_branch — every renderView branch line must contain both a render and a load.

Both carry an extractor self-proof and synthetic input (a hand-written roster / a branch without a loader must go red).

A/B (both instruments, four legs)

DOM probe: jsdom booting the real ui/index.html + the four real scripts, only fetch stubbed; drives the real nav buttons, the real logout button and the real login form; two accounts whose wallets differ and where available != balance.

leg probe red set gate red set
pre-change tree (a81839b:ui/js/app.js) {B1, B2, B3} {identity, view-router}
loader only, caches never cleared {B3} {identity}
caches cleared, no loader {B1, B2} {view-router}
this branch {} (13/13) {} (6/6)

The three red sets are mutually disjoint and their union is the pre-change red set — i.e. each half-fix fixes exactly one face and leaves the other. Both half-fixes are also rejected by the static gate, which is the evidence that the gate (not the DOM probe) pins the direction.

Probe faces:

  • B1#wallet-forever shows the signed-in account's permanent balance (was Alice's 4,242.42424 instead of Bob's 7.5);
  • B2 — the wallet view issues a /api/wallet request of its own (was none);
  • B3 — landing on the dashboard, the synchronous first frame never shows another account's numbers (was Alice's month_use = 12,345.6789).

Note on B3: it only has teeth when Bob lands on the dashboard. If he lands on the wallet, the wallet's new loader refreshes the shared Live.wallet slot during login and the leak becomes invisible — which is exactly why the "loader only" half-fix can pass a naively-built probe.

Checklist

  • 分支命名符合约定(fix/
  • Commit message 使用 Conventional Commits 格式
  • 单一职责,改动最小化

…allet view a loader

`Live` is a per-session cache, but nothing ever cleared it when the identity
changed, and `renderView`'s wallet branch was the only one without a loader. So
after Alice used the dashboard and logged out, Bob opening the wallet saw Alice's
`Live.wallet.balance` in `#wallet-forever` ("permanent points") — and it never
self-healed, because that view never fetched anything itself.

Two halves, both required:

- `resetSessionCaches()` — derived from the `Live` object literal, so a newly
  added slot is covered automatically — is called at both identity boundaries:
  session end (`exitGuest`: logout / 401) and session establishment
  (`loadSession`: boot / login).
- `renderView("wallet")` gets its own loader `loadWallet()`, mirroring the tail
  of `loadDashboard`. This also fixes a single-session bug: logging in and going
  straight to the wallet left `Live.wallet` null forever, so the cell fell back
  to `D.USER.balance` (`available` = permanent + today's gift) as if it were the
  permanent balance.

Static gate (`src/state_gate.rs`, test-only) pins both shapes:

- every `Live` slot is dropped at both identity boundaries, by a derived reset
  (a hand-written roster is rejected);
- every `renderView` branch both renders and loads.

A/B (jsdom, real index.html + 4 scripts, only fetch stubbed): pre-change tree red
`{B1,B2,B3}`; "loader only, caches never cleared" red `{B3}` (the dashboard still
renders the previous account's numbers on its first frame); "caches cleared,
no loader" red `{B1,B2}` (the wallet cell falls back to available). The two
half-fix red sets are disjoint and their union is the pre-change red set; both
half-fixes also fail the new gate tests.
@argszero
argszero merged commit 0e4acc2 into main Sep 14, 2026
1 check passed
@argszero
argszero deleted the fix/session-cache-identity-leak branch September 14, 2026 22:50
@argszero argszero mentioned this pull request Sep 15, 2026
10 tasks
argszero added a commit that referenced this pull request Sep 15, 2026
Ships the 18 PRs merged since v0.7.24 (#242-#259). Schema 14 -> 15 (two
covering indexes, applied at startup). No config change, so no deployment-side
config.toml edit is needed.

Two themes:

Perf on the NFS dev database
- #259: stop mapping the db (PRAGMA mmap_size 64MB -> 0) and stop a real write
  per request (dao::touch_api_key gains a 60s guard). Measured on the live dev
  db: mmap=64MB 1.7-3.1s per COUNT / 250 MiB read vs mmap=0 ~10.5ms / 80 KiB;
  mmap=0 alone still leaves ~1.2s behind any write, so the pair is required.
- #242: codify the two emergency indexes in a v15 migration and gate the
  conditional joins at the plan level.
- #243: read the sharing page's earn total from one batched aggregate.

Frontend: display must equal what it filters on, and one fact, one source
- #250 one writer for the transaction cache; #251 clear every session slot at
  the identity boundary and give the wallet view a loader; #253 one shared
  writer for the wallet/dashboard month-changes; #254 boot loads only the
  destination view; #255 a model row's identity is the model, not its index;
  #256 the marketplace source follows the session, not whether data arrived;
  #257 the sidebar advertises only digits that work; #258 the admin
  total-balance card sums the gift amount its caption names.

i18n
- #249 every backend error reaches the wordlist, and the comment stripper stops
  mangling UTF-8; #252 the backend stops inventing Chinese display labels in
  response data fields.

Forms and robustness
- #244 a non-auth boot failure no longer looks like being logged out; #245 a
  credential 401 is no longer read as a session expiry; #246 wire timestamps
  reach the renderer unsliced; #247 inline cards submit from every field; #248
  a market row's availability label comes from that row.

- Cargo.toml / Cargo.lock: 0.7.24 -> 0.7.25.
- CHANGELOG.md: v0.7.25 entry.
- ui/index.html: cache-bust left as-is; the UI PRs in this release already
  advanced it past the value deployed with v0.7.24 (app.js 20260915-13,
  i18n.js 20260915-3).

cargo test 288 passed; cargo fmt --check clean; clippy unchanged.
argszero added a commit that referenced this pull request Sep 21, 2026
`exitGuest()` is the identity boundary, but it only hid `#app`. The
panels that exist only while a session is live — `#help-panel`,
`#chat-modal` and the first-login tour — are *siblings* of `#app` in
`ui/index.html`, so hiding `#app` left them on screen over the login
form, and the next session inherited them (with `#help-context` still
naming the previous user's view, and nothing ever repainting it).

The boundary already owns the closers: the same file closes these
panels on Escape (`app.js:4260`/`:4262`). This adds one named function,
`resetSessionOverlays()`, that hands each `#app`-external panel back to
its own closer, and calls it from `exitGuest()` right after
`resetSessionCaches()` — the same direction #251 took one PR ago.

The new invariant `the_identity_boundary_closes_the_panels_outside_the_app`
derives the required element set from `ui/index.html` (top-level elements
after `#app` whose `class` carries the standalone token `hidden`) and
compares it against the transitive call closure of that one function.
Zero exemption list: adding a sixth panel turns CI red.
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.

1 participant