From 38c0ee01cb7394a9c8e8bb491d5b1e921cbe14d7 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:13:05 +1000 Subject: [PATCH 1/3] feat(session): add accidental-leave grace period --- ARCHITECTURE.md | 43 ++++++++++++++------------- PLAN.md | 2 +- src/features/session/Report.tsx | 12 ++++---- src/features/session/lifecycle.ts | 49 +++++++++++++------------------ src/routes/Home.tsx | 11 +++++-- src/stores/sessionStore.ts | 11 ++++--- src/strings.ts | 2 +- tests/unit/session-grace.test.ts | 36 +++++++++++++++++++---- 8 files changed, 94 insertions(+), 72 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f6d62733..4f66449d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -749,30 +749,31 @@ The `Ctrl+]` AI dialog is a separate Tauri window with: │ room empties (peer count 1) │ - ┌─────────────────────────────┤ - │ every departure explained │ - │ (each peer broadcast a │ - │ signed `left` first) ▼ - │ [20 s grace window (S1)] - │ │ │ - │ a peer reconnects expires - │ │ │ - ▼ ▼ ▼ - [end now: persist row, [media live] [auto-end: persist row, - generate report, (resume) generate report] - reason `peer`] │ - │ Report offers Rejoin (#47 B3, - │ auto-ends only; re-entry merges - │ into the same sessions row) - │ │ - └──────────────────┬────────────────┘ - ▼ - [tear down, return to idle] + ▼ + [20 s grace window (S1)] + │ │ + peer reconnects/rejoins expires + │ │ + ▼ ▼ + [media live] [persist row, + (resume) generate report] + │ + ┌─────────────────┴─────────────────┐ + │ unexplained loss signed `left` │ + ▼ ▼ + [reason `auto`] [reason `peer`] + │ + Report offers Rejoin (#47 B3; + re-entry merges into the same row) + │ + └─────────────────┬─────────────────┘ + ▼ + [tear down, return to idle] ``` -A deliberate local Leave skips the grace window: it persists and reports immediately with reason `user` (no Rejoin offer). +A deliberate local Leave persists and reports immediately with reason `user`, but the other participant keeps the room alive for the same 20-second grace period. The leaver's report offers Rejoin during that window; re-entry uses the existing topic and password and merges the new stint into the same session row. -A deliberate *remote* Leave skips it too. `handleLeave` broadcasts a signed `left` audit event and awaits it before `room.leave()`; both ride the same ordered data channel, so the receiver has the peer marked as departed before trystero reports the departure. When the room empties and **every** departure since the last join was marked that way, the session ends immediately with reason `peer` — no waiting for a friend who isn't coming back, and no Rejoin button into a room nobody is in. Any unmarked departure (crash, kill, tray-quit, transport drop) keeps the full grace window and the `auto` + Rejoin path, and a peer re-invited into the still-live session clears their mark on rejoin so a later blip of theirs is debounced again. +`handleLeave` broadcasts a signed `left` audit event and awaits it before `room.leave()`; both ride the same ordered data channel, so the receiver can retain accurate end attribution while still applying the shared grace period. If the peer returns before expiry, the pending end is cancelled and their departure mark is cleared. If the room remains empty, an unexplained departure ends with reason `auto`, while a fully explained deliberate departure ends with reason `peer`. Session-cap eviction remains non-rejoinable. ## 14. Threat model & known limitations diff --git a/PLAN.md b/PLAN.md index 809f7726..379b48f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -59,7 +59,7 @@ A complete, polished video-study app for friends. Zero AI code present. The app - Session room — full-mesh WebRTC video + audio. Default-muted with `Ctrl+[` / `Cmd+[` push-to-talk for friends. Per-tile presence indicators (online / on-break / disconnected). Audit log panel showing per-user events: joined, took break, returned, left. - Pomodoro timer — opt-in, synced across all users via WebRTC data channel. Broadcaster role transfers on disconnect. - Free-form sessions also supported (no timer). -- Session ends when only one user remains; each user can leave individually. +- Session ends after a 20-second grace period when only one user remains; each user can leave individually and rejoin during that window after an accidental exit. - System tray + autostart-at-login (opt-in) so the user is reachable for invites. - Onboarding — welcome → permissions → identity setup (with BIP39 backup) → add first friend (or skip) → tutorial. - Settings — friends management, identity export/import, autostart toggle, PTT keybindings (fixed defaults; rebinding lands in V3), theme (dark / light / auto), notification preferences. diff --git a/src/features/session/Report.tsx b/src/features/session/Report.tsx index 79e1e6fc..fa4104c1 100644 --- a/src/features/session/Report.tsx +++ b/src/features/session/Report.tsx @@ -85,12 +85,12 @@ export type ReportProps = { // closing the report drops the UI back to the friends list; the Settings // → Sessions re-open passes a back-to-list handler instead. onClose: () => void - // #47 B3 — present only when the session auto-ended (S1 grace expiry) and - // the store still holds the topic + password: a >20s blip strands a guest - // while the room may still be live, invites are one-shot, and the host is - // heads-down. Rejoining re-enters the same room; the second leave cycle - // merges into the topic-keyed row (mergeSessionStints in lifecycle.ts), - // so the report after a rejoin shows accumulated whole-session totals. + // #47 B3 / #190 — present when a transport-loss grace period expired or + // the local user deliberately left while the remote room may still be in + // its matching 20-second grace period. Rejoining re-enters the same room; + // the second leave cycle merges into the topic-keyed row + // (mergeSessionStints in lifecycle.ts), so the report after a rejoin shows + // accumulated whole-session totals. onRejoin?: () => void // Issue #161 — only the just-ended Home route opts into this action. A // report reopened from Settings must not label today's logs as belonging to diff --git a/src/features/session/lifecycle.ts b/src/features/session/lifecycle.ts index 60996367..2563785e 100644 --- a/src/features/session/lifecycle.ts +++ b/src/features/session/lifecycle.ts @@ -421,11 +421,11 @@ export type RoomLifecycle = { peers: () => readonly string[] } -// S1 — grace window before the everyone-else-left auto-end fires. A WiFi blip -// drops the transport to every peer at once and trystero fires onPeerLeave for -// each, crashing the count to 0; without a debounce a 5-second hiccup -// irreversibly ends a 90-minute session. We arm a timer when the room empties -// and only run the leave handler if it's STILL empty when the timer expires. +// S1 / #190 — grace window before the everyone-else-left auto-end fires. A +// WiFi blip or an accidental Leave can drop the transport to every peer at +// once; without a debounce either event irreversibly ends a long session. We +// arm a timer whenever the room empties and only run the leave handler if it is +// STILL empty when the timer expires. // trystero re-fires onPeerJoin on reconnect (and the cumulative // seenPeerEdPubkeys set in the session store survives the gap, so the report // still records who we studied with). Injectable scheduler so the unit tests @@ -448,7 +448,7 @@ const defaultGraceScheduler: GraceScheduler = { // host enforces the 4-user cap here (rejects the 4th remote peer); guests // listen for 'session-full' and tear down with a toast. Both sides auto-end // when peer count stays at 0 for DISCONNECT_GRACE_MS after at least one peer -// was present. +// was present, including after a signed deliberate Leave. export function wireSessionRoom( room: TopicRoom, hooks: WireHooks, @@ -460,10 +460,9 @@ export function wireSessionRoom( let hadAny = false // Peers that vanished WITHOUT the signed 'left' broadcast a deliberate Leave // sends first, and haven't returned. Tracked per-peer (not a single flag) so - // an intervening join by a DIFFERENT peer can't erase the memory of one still - // absent: the grace window is skipped only when this set is empty. In a - // 3-way session where one peer leaves on purpose and another blips we still - // wait, which is the safe way to be wrong. + // an intervening join by a DIFFERENT peer cannot erase the memory of one + // still absent. The set controls end attribution after the shared grace + // window: unexplained absence is `auto`; explained absence is `peer`. const unexplainedAbsent = new Set() let graceHandle: number | null = null const sessionFull = room.makeAction(SESSION_FULL_ACTION) @@ -492,11 +491,13 @@ export function wireSessionRoom( graceMs, unexplainedPeerCount: unexplainedAbsent.size, }) - // #47 B3 — stage the reason BEFORE the leave handler runs (first - // writer wins; the handler itself stages 'user') so markEnded - // records this as an auto-end and the Report can offer Rejoin (the - // room may still be live without us after a >20s blip). - useSessionStore.getState().setPendingEndReason('auto') + // Preserve why the room emptied while still giving every departure + // the same recovery window. An unexplained absence may be a transport + // blip, while a signed `left` means the peer chose Leave and simply + // did not rejoin before the deadline. + useSessionStore + .getState() + .setPendingEndReason(unexplainedAbsent.size > 0 ? 'auto' : 'peer') void hooks.leave() } }, graceMs) @@ -507,6 +508,7 @@ export function wireSessionRoom( log.warn('session_full.received', { role: 'guest' }) toast.error(SESSION_FULL_MESSAGE) cancelGrace() + useSessionStore.getState().setPendingEndReason('peer') void hooks.leave() }) } @@ -532,8 +534,8 @@ export function wireSessionRoom( } return } - // A (re)join cancels a pending auto-end: the transport recovered before - // the grace window expired. + // A (re)join cancels the pending end: either the transport recovered or a + // user reversed an accidental Leave before the grace window expired. cancelGrace() unexplainedAbsent.delete(peerId) peers.add(peerId) @@ -558,18 +560,7 @@ export function wireSessionRoom( explained, }) if (peers.size === 0 && hadAny) { - if (unexplainedAbsent.size > 0) { - armGrace() - } else { - // Every peer that left told us so first, so the room is provably - // empty: end now instead of showing "waiting for your friend to - // reconnect" for 20 s about someone who isn't coming back. 'peer' - // (not 'auto') keeps the Report from offering Rejoin into a room - // nobody is in. - cancelGrace() - store.setPendingEndReason('peer') - void hooks.leave() - } + armGrace() } }) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 0096226a..138bbbc5 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -272,8 +272,9 @@ export function Home() { const aiOn = () => useSettingsStore.getState().values.aiFeaturesEnabled - // #47 B3 — re-enter the still-live room after a grace-window auto-end. - // Reuses the already-declared topic (no AI topic-gate re-prompt) and the + // #47 B3 / #190 — re-enter the still-live room after a grace-window + // auto-end or an accidental local Leave. Reuses the already-declared topic + // (no AI topic-gate re-prompt) and the // credentials the store holds until the Report closes; joinSession's // begin() flips status to 'active', unmounting the Report. const handleRejoin = useCallback(() => { @@ -498,7 +499,11 @@ export function Home() { useSessionStore.getState().reset()} - onRejoin={sessionEndedBy === 'auto' ? handleRejoin : undefined} + onRejoin={ + sessionEndedBy === 'auto' || sessionEndedBy === 'user' + ? handleRejoin + : undefined + } showDiagnosticsExport /> {tail} diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index e395c9e7..5a56fcd0 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -12,12 +12,11 @@ import type { TopicRoom } from '@/lib/trystero' export type SessionStatus = 'idle' | 'active' | 'ended' -// #47 B3 — why the last session ended. 'auto' = the S1 grace window expired -// (a >20s connection blip), which is the one case where the room may still -// be live without us — the Report offers Rejoin there. 'peer' = every peer -// that left broadcast a signed 'left' first, so the room is provably empty -// and Rejoin would land in a dead room. 'user' covers every deliberate local -// path (Leave click, double-Esc, confirmed quit, session-full eviction). +// #47 B3 / #190 — why the last session ended. 'auto' = the S1 grace window +// expired after an unexplained transport loss. 'peer' = the grace window +// expired after every absent peer broadcast a signed `left`, or this client +// was evicted from a full room. 'user' covers a deliberate local Leave and is +// rejoinable while the remote peer's matching grace window remains open. // null until a session has ended. export type SessionEndReason = 'user' | 'auto' | 'peer' diff --git a/src/strings.ts b/src/strings.ts index 184894b6..bd26c27f 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -159,7 +159,7 @@ export const strings = { }, leave: { title: 'Leave any time', - body: 'Click Leave to drop out. The session ends for everyone when only one of you is left.', + body: 'Click Leave to drop out. If that was a mistake, rejoin within 20 seconds before the session ends.', }, ai: { title: 'AI is optional', diff --git a/tests/unit/session-grace.test.ts b/tests/unit/session-grace.test.ts index 7e218ec6..685bfbd4 100644 --- a/tests/unit/session-grace.test.ts +++ b/tests/unit/session-grace.test.ts @@ -174,7 +174,7 @@ describe('S1 disconnect grace window', () => { ) }) - test("a peer that broadcast 'left' ends the session at once, with no grace window", () => { + test("a peer that broadcast 'left' gets the same rejoin grace window", () => { const { room, join, leave } = fakeRoom() const sched = fakeScheduler() const onLeave = vi.fn(async () => {}) @@ -190,12 +190,38 @@ describe('S1 disconnect grace window', () => { // which is always before trystero's own leave notification. useSessionStore.getState().markPeerDeparted('peer-a') leave('peer-a') + expect(onLeave).not.toHaveBeenCalled() + expect(sched.pending()).toBe(1) + sched.advance(DISCONNECT_GRACE_MS - 1) + expect(onLeave).not.toHaveBeenCalled() + sched.advance(1) expect(onLeave).toHaveBeenCalledTimes(1) - expect(sched.pending()).toBe(0) - // 'peer' (not 'auto') so the Report suppresses Rejoin into a dead room. + // The recovery window does not erase the signed departure semantics. expect(useSessionStore.getState().pendingEndReason).toBe('peer') }) + test('an accidental deliberate leave can rejoin before expiry', () => { + const { room, join, leave } = fakeRoom() + const sched = fakeScheduler() + const onLeave = vi.fn(async () => {}) + + wireSessionRoom( + room, + { isHost: true, leave: onLeave }, + { scheduler: sched.scheduler } + ) + + join('peer-a') + useSessionStore.getState().markPeerDeparted('peer-a') + leave('peer-a') + sched.advance(DISCONNECT_GRACE_MS - 1) + join('peer-a') + expect(sched.pending()).toBe(0) + expect(useSessionStore.getState().departedPeerIds).not.toContain('peer-a') + sched.advance(DISCONNECT_GRACE_MS) + expect(onLeave).not.toHaveBeenCalled() + }) + test('a deliberate departure alongside an unexplained one still waits out the window', () => { const { room, join, leave } = fakeRoom() const sched = fakeScheduler() @@ -275,8 +301,8 @@ describe('S1 disconnect grace window', () => { leave('peer-b') useSessionStore.getState().markPeerDeparted('peer-c') leave('peer-c') - // The room is empty but peer-a's departure was never explained, so we must - // wait out the window rather than kill the room instantly. + // The room is empty and peer-a's departure was never explained, so expiry + // remains attributed to a transport-style auto-end. expect(onLeave).not.toHaveBeenCalled() expect(sched.pending()).toBe(1) sched.advance(DISCONNECT_GRACE_MS) From b7ce486c052b239197c740c6fa987e0389c130ba Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:41:05 +1000 Subject: [PATCH 2/3] fix(session): harden grace-period rejoin --- ISSUES.md | 2 +- src/features/session/Report.tsx | 36 +++++++++++++++++++++++- src/features/session/index.ts | 3 +- src/features/session/join.ts | 27 +++++++++++++++--- src/features/session/lifecycle.ts | 6 ++++ src/routes/Home.tsx | 17 +++++++++--- src/stores/sessionStore.ts | 40 +++++++++++++++++++++++++-- src/strings.ts | 4 +-- tests/integration/session.test.ts | 22 ++++++++++++++- tests/unit/session-end-reason.test.ts | 38 +++++++++++++++++++++++-- 10 files changed, 175 insertions(+), 20 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 7c329f2f..a7190c9d 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -436,7 +436,7 @@ Format: one `###` section per finding, ordered by ID. Entries are appended, neve **Evidence.** A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. -**Status.** **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. +**Status.** **superseded by #190** — all empty-room departures now receive the same 20-second recovery window. Signed `left` events still preserve accurate `peer` attribution at expiry, while unexplained loss remains `auto`; a returning peer cancels the pending end and clears only its own departure mark. Rejoin is deadline-guarded and preserves the prior host/guest role. ### I54 — Sev3 diff --git a/src/features/session/Report.tsx b/src/features/session/Report.tsx index fa4104c1..a71fa704 100644 --- a/src/features/session/Report.tsx +++ b/src/features/session/Report.tsx @@ -92,6 +92,9 @@ export type ReportProps = { // (mergeSessionStints in lifecycle.ts), so the report after a rejoin shows // accumulated whole-session totals. onRejoin?: () => void + // Absolute deadline captured before teardown begins. The report may spend + // part of the window loading, so it must not start a fresh 20-second timer. + rejoinDeadline?: number // Issue #161 — only the just-ended Home route opts into this action. A // report reopened from Settings must not label today's logs as belonging to // an older session. @@ -111,6 +114,14 @@ type Status = | { kind: 'error'; message: string } | { kind: 'ready'; data: ResolvedReportData } +function isRejoinAvailable( + onRejoin: (() => void) | undefined, + deadline: number | undefined, + now = Date.now() +): boolean { + return onRejoin !== undefined && (deadline === undefined || now < deadline) +} + // Default loader used in production. Storybook stories override via // `__loader`. Splitting it out keeps the React component's effect body // focused on lifecycle, not data plumbing. @@ -142,6 +153,7 @@ export function Report({ sessionId, onClose, onRejoin, + rejoinDeadline, showDiagnosticsExport = false, __loader, }: ReportProps) { @@ -250,6 +262,7 @@ export function Report({ data={status.data} onClose={onClose} onRejoin={onRejoin} + rejoinDeadline={rejoinDeadline} showDiagnosticsExport={showDiagnosticsExport} /> ) @@ -263,6 +276,7 @@ export type ReportViewProps = { onClose: () => void // #47 B3 — see ReportProps.onRejoin. onRejoin?: () => void + rejoinDeadline?: number // See ReportProps.showDiagnosticsExport. showDiagnosticsExport?: boolean // Disables the on-mount ScoreGauge sweep so Storybook snapshots stay @@ -274,6 +288,7 @@ export function ReportView({ data, onClose, onRejoin, + rejoinDeadline, showDiagnosticsExport = false, animateScore = true, }: ReportViewProps) { @@ -303,6 +318,20 @@ export function ReportView({ // work" is the contradiction issue #92 screenshotted. const coverage = aiCoverage(session) + const [expiredDeadline, setExpiredDeadline] = useState(null) + const rejoinAvailable = + isRejoinAvailable(onRejoin, rejoinDeadline) && + expiredDeadline !== rejoinDeadline + useEffect(() => { + if (!isRejoinAvailable(onRejoin, rejoinDeadline)) return + if (rejoinDeadline === undefined) return + const handle = setTimeout( + () => setExpiredDeadline(rejoinDeadline), + Math.max(0, rejoinDeadline - Date.now()) + ) + return () => clearTimeout(handle) + }, [onRejoin, rejoinDeadline]) + const [copied, setCopied] = useState(false) const copyTimer = useRef | null>(null) useEffect(() => { @@ -481,7 +510,12 @@ export function ReportView({ {exportCopy.auditCta} {onRejoin ? ( - ) : null} diff --git a/src/features/session/index.ts b/src/features/session/index.ts index 55ddd6f7..7d7b6a83 100644 --- a/src/features/session/index.ts +++ b/src/features/session/index.ts @@ -1,5 +1,5 @@ export { hostSession } from './host' -export { joinSession } from './join' +export { joinSession, rejoinSession } from './join' export { inviteToCurrentSession, InviteWhileGuestError } from './invite' export { SessionView, type SessionViewProps } from './SessionView' export { @@ -9,6 +9,7 @@ export { export { invitableFriends } from './invitableFriends' export { TopicGateModal, type TopicGateModalProps } from './TopicGateModal' export { + DISCONNECT_GRACE_MS, MAX_REMOTE_PEERS, PTT_STATE_ACTION, SESSION_FULL_ACTION, diff --git a/src/features/session/join.ts b/src/features/session/join.ts index 99cd777a..265c998e 100644 --- a/src/features/session/join.ts +++ b/src/features/session/join.ts @@ -10,11 +10,30 @@ import { } from './lifecycle' // Joins an existing trystero room with the password from the invite envelope. -// Updates the session store as a guest (isHost: false) and returns a handle -// whose `leave` tears the room down + persists the row. +// Updates the session store as a guest and returns a handle whose `leave` +// tears the room down + persists the row. export function joinSession( sessionTopic: string, sessionPassword: string +): SessionHandle { + return joinExistingSession(sessionTopic, sessionPassword, false) +} + +// Re-enter the same room while preserving the role held by the prior stint. +// The original host must remain responsible for participant-cap enforcement +// and retain the ability to invite friends after an accidental Leave. +export function rejoinSession( + sessionTopic: string, + sessionPassword: string, + isHost: boolean +): SessionHandle { + return joinExistingSession(sessionTopic, sessionPassword, isHost) +} + +function joinExistingSession( + sessionTopic: string, + sessionPassword: string, + isHost: boolean ): SessionHandle { // S2 — clear any PTT latched by a dropped Released event before the media- // acquire effect reads it, so the first audio track never comes up live. @@ -30,13 +49,13 @@ export function joinSession( useSessionStore.getState().begin({ sessionTopic: topic, sessionPassword: password, - isHost: false, + isHost, startedAt, startedAtMono, room, leave, }) - const lifecycle = wireSessionRoom(room, { isHost: false, leave }) + const lifecycle = wireSessionRoom(room, { isHost, leave }) return { sessionTopic: topic, sessionPassword: password, diff --git a/src/features/session/lifecycle.ts b/src/features/session/lifecycle.ts index 2563785e..66db9b61 100644 --- a/src/features/session/lifecycle.ts +++ b/src/features/session/lifecycle.ts @@ -274,6 +274,11 @@ export function buildLeaveHandler(args: { // practice — but capturing up front decouples us from that gate. const sessionState = useSessionStore.getState() const endReason = sessionState.pendingEndReason ?? 'user' + const rejoinable = + sessionState.hadAnyPeer && (endReason === 'auto' || endReason === 'user') + sessionState.setRejoinDeadline( + rejoinable ? endedAt + DISCONNECT_GRACE_MS : null + ) const peerPubkeys = sessionState.collectPeerPubkeys() // Cumulative set, not the live `peers` map: on the everyone-else-leaves // auto-end path `peerLeft` has already pruned every entry by now. @@ -389,6 +394,7 @@ export function buildLeaveHandler(args: { } log.info('session.end_completed', { endReason, + rejoinDeadline: rejoinable ? endedAt + DISCONNECT_GRACE_MS : null, totalMinutes: merged.totalMinutes, seenPeerCount: peerEdPubkeys.length, scoreRecorded: focusSnapshot.score !== null, diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 138bbbc5..c522f3fd 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -51,6 +51,7 @@ import { inviteToCurrentSession, InviteWhileGuestError, joinSession, + rejoinSession, Report, SessionView, TopicGateModal, @@ -83,9 +84,11 @@ export function Home() { const settingsStatus = useSettingsStore((s) => s.status) const sessionStatus = useSessionStore((s) => s.status) const sessionTopic = useSessionStore((s) => s.sessionTopic) - // #47 B3 — 'auto' (S1 grace expiry) is the one end the Report offers - // Rejoin for; the store keeps topic+password until the Report closes. + // #47 B3 / #190 — auto and deliberate local endings may be rejoinable + // until the teardown-time deadline. The store retains credentials and the + // prior role until the report closes or a successful rejoin begins. const sessionEndedBy = useSessionStore((s) => s.endedBy) + const rejoinDeadline = useSessionStore((s) => s.rejoinDeadline) const [addOpen, setAddOpen] = useState(false) // F10 — words prefilled into the Add-friend Enter-code tab from an OS deep // link. Set alongside opening the dialog on the join tab; never auto-connects. @@ -278,8 +281,9 @@ export function Home() { // credentials the store holds until the Report closes; joinSession's // begin() flips status to 'active', unmounting the Report. const handleRejoin = useCallback(() => { + const request = useSessionStore.getState().getRejoinRequest() + if (!request) return const s = useSessionStore.getState() - if (!s.sessionTopic || !s.sessionPassword) return // I83 — Rejoin skips the topic gate, so it also used to skip the only // gesture-context screen pre-acquire (handleTopicSubmit's). This click IS // a user gesture; spend it the same way, or the rejoined session's boot() @@ -294,7 +298,11 @@ export function Home() { s.setPendingInitialTopic(s.declaredStudyTopic) } try { - joinSession(s.sessionTopic, s.sessionPassword) + rejoinSession( + request.sessionTopic, + request.sessionPassword, + request.isHost + ) } catch (err) { // I83 — the rejoin failed, so no SessionView will mount to consume (or // unmount to discard) the stream pre-acquired a few lines up. Release it @@ -504,6 +512,7 @@ export function Home() { ? handleRejoin : undefined } + rejoinDeadline={rejoinDeadline ?? undefined} showDiagnosticsExport /> {tail} diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 5a56fcd0..80625ffd 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -20,6 +20,12 @@ export type SessionStatus = 'idle' | 'active' | 'ended' // null until a session has ended. export type SessionEndReason = 'user' | 'auto' | 'peer' +export type SessionRejoinRequest = { + sessionTopic: string + sessionPassword: string + isHost: boolean +} + // Mirrors the validated payload shape returned by the V1-P9 signed-hello // handshake. Inlined here so the store does not import a feature module // (keeps stores → lib + zustand only, matching friendsStore / pttStore). @@ -69,6 +75,10 @@ type SessionState = { // attribution — a grace timer firing while a deliberate Leave's async // teardown is mid-flight can no longer rewrite it to 'auto'. pendingEndReason: SessionEndReason | null + // Absolute wall-clock deadline captured when teardown begins. It starts + // before room.leave(), persistence, and report loading, so slow cleanup + // cannot accidentally extend the remote peer's recovery window. + rejoinDeadline: number | null sessionTopic: string | null sessionPassword: string | null isHost: boolean @@ -117,9 +127,9 @@ type SessionState = { seenPeerNames: Record // trystero peerIds that broadcast a verified 'left' audit event this // session — the departures we can explain. The lifecycle wiring reads this - // to skip the S1 grace window when the room empties for a deliberate - // departure; `peerJoined` drops an id again so a re-invited peer's later - // blip still gets the window. Cleared by begin/reset. + // to preserve end attribution after the shared grace window; `peerJoined` + // drops an id again so a re-invited peer's later blip is treated as an + // unexplained disconnect. Cleared by begin/reset. departedPeerIds: string[] begin: (init: SessionInit) => void setPendingInitialTopic: (topic: string | null) => void @@ -135,6 +145,8 @@ type SessionState = { // populate sessions.peer_pubkeys. NULL until at least one hello arrived. collectPeerPubkeys: () => string | null setPendingEndReason: (reason: SessionEndReason) => void + setRejoinDeadline: (deadline: number | null) => void + getRejoinRequest: (now?: number) => SessionRejoinRequest | null // Flip status to 'ended' so Home.tsx can mount the post-session Report // (V2-P8). The Report queries SQLite for the just-persisted sessions // row + audit_events; the in-memory peers / displayNames aren't @@ -149,6 +161,7 @@ const INITIAL: Pick< | 'status' | 'endedBy' | 'pendingEndReason' + | 'rejoinDeadline' | 'sessionTopic' | 'sessionPassword' | 'isHost' @@ -168,6 +181,7 @@ const INITIAL: Pick< status: 'idle', endedBy: null, pendingEndReason: null, + rejoinDeadline: null, sessionTopic: null, sessionPassword: null, isHost: false, @@ -198,6 +212,7 @@ export const useSessionStore = create((set, get) => ({ status: 'active', endedBy: null, pendingEndReason: null, + rejoinDeadline: null, sessionTopic: init.sessionTopic, sessionPassword: init.sessionPassword, isHost: init.isHost, @@ -308,6 +323,25 @@ export const useSessionStore = create((set, get) => ({ set((s) => s.pendingEndReason === null ? { pendingEndReason: reason } : s ), + setRejoinDeadline: (deadline) => set({ rejoinDeadline: deadline }), + getRejoinRequest: (now = Date.now()) => { + const s = get() + if ( + s.status !== 'ended' || + (s.endedBy !== 'auto' && s.endedBy !== 'user') || + s.rejoinDeadline === null || + now >= s.rejoinDeadline || + !s.sessionTopic || + !s.sessionPassword + ) { + return null + } + return { + sessionTopic: s.sessionTopic, + sessionPassword: s.sessionPassword, + isHost: s.isHost, + } + }, markEnded: () => set((s) => s.status === 'active' diff --git a/src/strings.ts b/src/strings.ts index bd26c27f..f0d994e8 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -817,8 +817,8 @@ export const strings = { report: { notFound: 'Session not found.', loadErrorFallback: "Couldn't load the report.", - // #47 B3 — shown only after an S1 grace-window auto-end, where the room - // may still be live without you. + // #47 B3 / #190 — shown after an eligible auto or local-user ending + // while the remote room's teardown-time grace deadline is still open. rejoinCta: 'Rejoin session', // #47 D5 — calm data-quality caveat when a material share of AI checks // couldn't be read (see sampleQualitySummary): the focused-time % above diff --git a/tests/integration/session.test.ts b/tests/integration/session.test.ts index 2c2fbef9..276c8de9 100644 --- a/tests/integration/session.test.ts +++ b/tests/integration/session.test.ts @@ -195,7 +195,7 @@ vi.mock('@/lib/trystero', () => { } }) -import { hostSession, joinSession } from '@/features/session' +import { hostSession, joinSession, rejoinSession } from '@/features/session' import { MAX_REMOTE_PEERS } from '@/features/session/lifecycle' import { useSessionStore } from '@/stores/sessionStore' @@ -287,6 +287,26 @@ describe('mesh hard-cap', () => { await host.leave() for (const g of guests) await g.leave() }) + + test('a rejoined host preserves host role and cap enforcement', async () => { + const topic = 'rejoin-host-topic' + const password = 'rejoin-host-password' + const host = rejoinSession(topic, password, true) + expect(useSessionStore.getState().isHost).toBe(true) + + const guests = [ + joinSession(topic, password), + joinSession(topic, password), + joinSession(topic, password), + joinSession(topic, password), + ] + await flushMicrotasks(12) + + expect(host.peers()).toHaveLength(MAX_REMOTE_PEERS) + + await host.leave() + for (const guest of guests) await guest.leave() + }) }) describe('leave handler tears down the room and persists a sessions row', () => { diff --git a/tests/unit/session-end-reason.test.ts b/tests/unit/session-end-reason.test.ts index 75b6b285..62b458ea 100644 --- a/tests/unit/session-end-reason.test.ts +++ b/tests/unit/session-end-reason.test.ts @@ -3,9 +3,8 @@ import { beforeEach, describe, expect, test } from 'vitest' import type { TopicRoom } from '@/lib/trystero' import { useSessionStore } from '@/stores/sessionStore' -// #47 B3 — markEnded records WHY the session ended so the Report can offer -// Rejoin exactly for the S1 grace-window auto-end (the one case where the -// room may still be live without us). +// #47 B3 / #190 — markEnded records why the session ended and the store +// rejects stale report actions once the teardown-time rejoin deadline passes. function begin(): void { useSessionStore.getState().begin({ @@ -87,6 +86,39 @@ describe('session end reason (#47 B3)', () => { expect(useSessionStore.getState().endedBy).toBe('auto') }) + test.each(['auto', 'user'] as const)( + 'rejects a late rejoin request after a %s ending', + (reason) => { + begin() + useSessionStore.getState().setPendingEndReason(reason) + useSessionStore.getState().setRejoinDeadline(20_000) + useSessionStore.getState().markEnded() + + expect(useSessionStore.getState().getRejoinRequest(19_999)).toEqual({ + sessionTopic: 'topic', + sessionPassword: 'pw', + isHost: false, + }) + expect(useSessionStore.getState().getRejoinRequest(20_000)).toBeNull() + } + ) + + test('preserves the prior role in an eligible rejoin request', () => { + useSessionStore.getState().begin({ + sessionTopic: 'topic', + sessionPassword: 'pw', + isHost: true, + startedAt: 1_700_000_000_000, + room: {} as TopicRoom, + leave: async () => {}, + }) + useSessionStore.getState().setRejoinDeadline(20_000) + useSessionStore.getState().markEnded() + expect(useSessionStore.getState().getRejoinRequest(19_999)?.isHost).toBe( + true + ) + }) + test('reset clears the reason', () => { begin() useSessionStore.getState().markEnded() From a1caad43013dc6d77b2f31995ed12722bdac55d6 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:01:13 +1000 Subject: [PATCH 3/3] fix(session): hide stale report rejoin --- src/routes/Home.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index c522f3fd..e145d87e 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -508,7 +508,8 @@ export function Home() { sessionId={sessionTopic} onClose={() => useSessionStore.getState().reset()} onRejoin={ - sessionEndedBy === 'auto' || sessionEndedBy === 'user' + rejoinDeadline !== null && + (sessionEndedBy === 'auto' || sessionEndedBy === 'user') ? handleRejoin : undefined }