Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 41 additions & 7 deletions src/features/session/Report.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,16 @@ 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
// 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.
Expand All @@ -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.
Expand Down Expand Up @@ -142,6 +153,7 @@ export function Report({
sessionId,
onClose,
onRejoin,
rejoinDeadline,
showDiagnosticsExport = false,
__loader,
}: ReportProps) {
Expand Down Expand Up @@ -250,6 +262,7 @@ export function Report({
data={status.data}
onClose={onClose}
onRejoin={onRejoin}
rejoinDeadline={rejoinDeadline}
showDiagnosticsExport={showDiagnosticsExport}
/>
)
Expand All @@ -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
Expand All @@ -274,6 +288,7 @@ export function ReportView({
data,
onClose,
onRejoin,
rejoinDeadline,
showDiagnosticsExport = false,
animateScore = true,
}: ReportViewProps) {
Expand Down Expand Up @@ -303,6 +318,20 @@ export function ReportView({
// work" is the contradiction issue #92 screenshotted.
const coverage = aiCoverage(session)

const [expiredDeadline, setExpiredDeadline] = useState<number | null>(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<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
Expand Down Expand Up @@ -481,7 +510,12 @@ export function ReportView({
<BracesIcon /> {exportCopy.auditCta}
</Button>
{onRejoin ? (
<Button variant="default" size="sm" onClick={onRejoin}>
<Button
variant="default"
size="sm"
onClick={onRejoin}
disabled={!rejoinAvailable}
>
<RotateCcwIcon /> {strings.report.rejoinCta}
</Button>
) : null}
Expand Down
3 changes: 2 additions & 1 deletion src/features/session/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down
27 changes: 23 additions & 4 deletions src/features/session/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
55 changes: 26 additions & 29 deletions src/features/session/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -421,11 +427,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
Expand All @@ -448,7 +454,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,
Expand All @@ -460,10 +466,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<string>()
let graceHandle: number | null = null
const sessionFull = room.makeAction<null>(SESSION_FULL_ACTION)
Expand Down Expand Up @@ -492,11 +497,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)
Expand All @@ -507,6 +514,7 @@ export function wireSessionRoom(
log.warn('session_full.received', { role: 'guest' })
toast.error(SESSION_FULL_MESSAGE)
cancelGrace()
useSessionStore.getState().setPendingEndReason('peer')
void hooks.leave()
})
}
Expand All @@ -532,8 +540,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)
Expand All @@ -558,18 +566,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()
}
})

Expand Down
Loading
Loading