- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine suspend; the reload client otherwise destroys the session under test. - `dev-peer.sh` plus a separate userdata dir runs a second local peer. - `tools/voice-probe.js` samples peer state and RTP counters from a live window, persisting to localStorage so a renderer reload cannot erase it. - e2e helpers for voice pairs, peer-role election, and a TURN relay. - Electron single-instance and dev-client-load decisions move into rules files with colocated specs.
23 KiB
Validated emergency findings — Fable 5 implementation handoff
Date: 2026-08-12
Scope: emergency-fix/, emergency-fix/e2e-failures/, Angular client, targeted Electron/auth bridge, signaling server, and relevant Playwright tests.
Method: Read-only code and test audit. No product code was changed and the E2E suite was not rerun for this report.
Executive verdict
The emergency pack is directionally correct, but it mixes active defects, already-shipped mitigations, hypotheses, and documentation debt. The current app is not yet demonstrated to be Discord-reliable despite a green 65/65 Playwright run.
The highest-confidence failure chain is:
- Restored sessions do not ensure a provision secret.
- A foreign signal credential cannot be created.
- The user is sent to authorize login or never identifies/joins.
- Presence is missing, so WebRTC discovery and WebSocket chat fallback fail.
- When peers do connect, negotiation still compares home ids against foreign actor ids.
- A data-channel failure tears down the entire peer connection, including media.
- Reconnect attempts can be exhausted while signaling is offline, then stop silently.
Independent P0/P1 defects also exist in direct-message identity, call delivery, and message synchronization:
- inbound DMs trust an actor-id conversation id and can fork a second local thread;
- an outbound call enters local voice state before delivery is known, and delivery failure is ignored;
- a timed-out inventory cycle is marked “clean” without proving convergence, delaying the next poll for 15 minutes.
Acceptance target
This emergency is not complete until two users with different home signal servers can:
- join the same foreign-hosted community without another login prompt;
- see each other online and in the correct voice channel;
- exchange live and catch-up text messages in both directions;
- place and answer a private call with bidirectional audio;
- keep one DM thread for the same human across actor aliases;
- transfer a file and verify received bytes;
- use camera and screen share while voice remains healthy;
- recover from a signal-server restart and a data-channel failure, or receive a visible retry action within one minute.
Finding classification
- Confirmed: direct current-code mechanism.
- Partial: some mechanism exists, but the report overstates or misattributes it.
- Mitigated: code already contains the reported fix; retain as a regression test.
- Unproven: plausible but needs reproduction evidence.
Confirmed findings
F1 — Restored sessions can lack a provision secret
Severity: P0
User symptom: A logged-in user opens a foreign room or invite and is shown /login?mode=authorize, or remains invisible on that signal server.
loadCurrentUserSuccess migrates the home credential and immediately attempts foreign provisioning:
users.effects.ts, lines 199–216
The restore path does not call ensureHomeProvisionSecret. That call currently exists only when storage is prepared from a fresh login response:
users.effects.ts, lines 288–306
Provisioning exits when the secret is absent:
signal-server-auth.service.ts, lines 154–168
The web store uses sessionStorage, so a new tab/session can lose the secret even while other persisted identity data remains. Electron storage is durable, but old installs or cleared user data can still have no secret.
Required fix: ensure or deliberately recover the home provision secret before any restore-time or join-time foreign provision. Do not silently swallow restore provisioning errors.
Important design caveat: generating a new secret cannot authenticate foreign accounts created with a lost old secret. The implementation must define recovery for that case instead of looping register/login forever.
F2 — Missing foreign credentials block presence and chat fallback
Severity: P0
User symptom: The room opens locally, but other users do not see the user; chat is sender-only; voice is empty.
The room connection refuses to proceed without a credential. The signaling transport skips identify if credentials cannot be resolved. The server rejects all non-keepalive, non-identify messages from unauthenticated connections.
Live WebSocket channel chat is also membership-gated:
server/src/websocket/handler.ts, lines 539–552
Therefore auth/presence must be repaired before treating chat or voice as isolated transport bugs.
Required fix: prove the complete sequence credential -> identify(actor id) -> join_server -> mutual roster, including reconnect.
F3 — WebRTC initiator and glare logic use the wrong identity space
Severity: P0
User symptom: Cross-home peers connect intermittently, both offer, both wait, or voice remains one-way/connecting.
Per-signal credentials exist through getIdentifyCredentialsForSignalUrl, but the generic getter prefers the home credential:
signaling-transport-handler.ts, lines 35–71
The peer manager and incoming signaling handler receive the generic home-biased getter:
realtime-session.service.ts, lines 198–208 and 232–234
Offer collision handling compares that id with the remote signal actor id:
negotiation.ts, lines 106–126
Initiator election performs the same lexical comparison:
signaling-message-handler.ts, lines 528–535
Required fix: carry connection/signal scope into initiator election, polite-peer handling, reconnect, and P2P voice-state payloads. Both peers must compare actor ids from the same signal URL.
F4 — Reconnect budget is consumed while signaling is unavailable
Severity: P0
User symptom: A signal outage lasts about one minute; signaling returns, but the peer mesh does not retry and no error is shown.
Each timer tick increments reconnectAttempts before checking signaling connectivity. At the limit, the timer and tracker are deleted:
peer-recovery.ts, lines 282–321
This means the client can spend all 12 attempts doing no actual reconnection work.
Required fix: do not consume an attempt until an offer/reconnect is actually attempted. On exhaustion, publish an explicit failed state with a user-visible Retry action. A later presence or signaling recovery event must be able to re-arm repair.
F5 — A closed data channel tears down media
Severity: P0
User symptom: Voice, camera, or screen share drops when chat/file control transport fails.
The active recovery path calls removePeer, then creates a new peer transport:
peer-recovery.ts, lines 208–235
The realtime README claims the initiator creates a replacement data channel on the existing RTCPeerConnection:
realtime/README.md, line 263
That claim is false for the current recovery path. replaceDataChannel exists but is not the implemented repair.
Required decision:
- Recommended emergency step: keep full rebuild initially, but force resync, preserve/reapply media state, expose progress/failure, and fix the docs.
- Follow-up reliability step: implement a true soft data-channel replacement on a healthy peer connection, with full rebuild as fallback.
F6 — Direct messages can fork by home id versus actor id
Severity: P0
User symptom: Two threads represent the same person; a reply lands in the thread the sender is not viewing.
The pair id is a literal sorted pair of supplied ids:
direct-message.logic.ts, lines 17–21
Inbound handling trusts payload.message.conversationId before deriving a local id and performs no alias merge:
direct-message.service.ts, lines 511–541
The lesson symbols resolveDirectConversationId and mergeAliasDirectConversations do not exist in the product tree.
Required fix: define a canonical local human identity, remap inbound actor ids, merge duplicate conversations transactionally, and preserve messages/unread/status ordering.
F7 — Outbound private calls can fail silently after local join
Severity: P0
User symptom: Caller sees “In Voice”; callee never rings.
startCall joins the call locally before sending the ring:
direct-call.service.ts, lines 220–235
sendCallEvent does not await or inspect delivery:
direct-call.service.ts, lines 759–773
Candidate expansion in PeerDeliveryService helps when a matching NgRx user alias exists, but there is no complete credential/presence-based routable-id selection and no unreachable UX.
Required fix: resolve the connected actor id before committing the caller to the session, return a delivery result, and transition to recipientUnreachable/Retry when no route accepts the ring.
F8 — Message synchronization can falsely become “clean”
Severity: P1, potentially P0 for long missing-history windows
User symptom: Live chat works, but old messages remain missing for up to 15 minutes.
Periodic sync sends inventory requests and dispatches startSync:
messages-sync.effects.ts, lines 166–213
Five seconds later, if the reducer still says syncing, the effect unconditionally sets lastSyncClean = true and dispatches syncComplete:
messages-sync.effects.ts, lines 215–232
No evidence of an empty inventory result is required. The next interval can therefore change from 10 seconds to 15 minutes after timeout rather than convergence.
Required fix: model an inventory round explicitly. Mark clean only when all expected peers have replied and no missing ids remain. Timeouts and newly received messages must remain dirty.
F9 — Live chat fallback and history repair are separate
Severity: P1
User symptom: New text arrives while older text, edits, files, and emoji remain absent.
The WebSocket chat_message path handles live channel messages for joined members. Inventory and sync batches are data-channel-only. message-revision has no equivalent WebSocket fallback.
onPeerConnected kicks inventory:
messages-sync.effects.ts, lines 64–94
That signal can occur at peer-connection state connected before the data channel is open; a second data-channel-open signal usually retries, but there is no explicit durable “control channel reopened and inventory completed” state.
Required fix: trigger and track reconciliation from data-channel open, not only generic peer connected. Queue failed inventory sends and retry on channel open.
F10 — STUN-only defaults cannot guarantee Discord-like reachability
Severity: P1 product limitation
User symptom: Voice works on friendly networks but fails on symmetric NAT, enterprise Wi-Fi, carrier networks, or restrictive firewalls.
TURN can be configured by the user, but no managed/default TURN service is supplied. Localhost E2E cannot validate NAT traversal.
Required product decision: operate a credentialed TURN service with expiry/abuse controls, or explicitly reject the requirement that voice “always works.” Documentation alone does not satisfy the stated target.
Claims that should not be treated as active defects
Identify cache fallback — mitigated
getIdentifyCredentialsForSignalUrl falls back to the credential store when the per-URL cache is empty:
signaling-transport-handler.ts, lines 47–71
Keep this as a regression invariant. Add a focused spec; do not rewrite this path without a failing reproduction.
Join-before-identify race — substantially mitigated
Client reconnect sends identify before rejoin, and the server serializes message handling per connection. The remaining P0 case is missing credentials, not evidence that server serialization is currently broken.
Shared clientInstanceId across tabs — fixed
Current code uses sessionStorage and clears the legacy local-storage value. Retain the existing regression test.
Multi-signal user_left teardown — mitigated
Current client/server paths carry server membership information and preserve peers still shared elsewhere. This needs multi-signal integration coverage, but the pack should not call it a confirmed current failure.
Voice allow-list alias miss — partial
The voice playback service already collects id, oderId, and peerId. More likely active failures are stale/missing voiceState, a peer key absent from all three aliases, or the broader home/actor negotiation mismatch. Instrument before changing routing filters.
“No retry after abandon” — overstated
Presence fallback timers can create a later offer. The confirmed defect is that the explicit reconnect tracker is silently discarded and signaling-down ticks consume its budget.
Attachment announce/message race — fixed, retain
The incoming message handler re-queues auto-download after binding message to room, with unit coverage. Add reorder/large-file E2E rather than reimplementing the fix.
E2E baseline assessment
The archived run is internally consistent:
- 65 passed
- 0 failed
- 0 flaky
- 0 skipped
- about 13.4 minutes
Sources:
e2e-failures/README.mde2e-failures/00-summary.mde2e-failures/parsed-report.jsone2e-failures/full-run.log
What it proves
- Scripted flows passed once in Chromium with retries disabled.
- The Angular development server, ephemeral localhost signaling servers, fake mic/camera, and synthetic screen capture can support the covered scenarios.
- Strong existing scenarios include two-user voice, eight-user local multi-signal voice, synthetic data-channel recovery, direct-call answer/audio on a same-route setup, chat sync, and several attachment regressions.
What it does not prove
The Playwright configuration runs only Chromium against ng serve:
e2e/playwright.config.ts, lines 3–39
It does not run Electron, a packaged production build, real desktop safeStorage/SQLite behavior, real screen capture, real TURN/NAT, sleep/wake, or a production signal deployment.
The cross-signal auth test adds the second endpoint manually before checking provision:
multi-signal-server-auth.spec.ts, lines 20–70
It does not cover restore with a missing secret or joining an unknown foreign invite.
The cross-signal call test proves only that the callee sees a ring:
dm-header-call-ring.spec.ts, lines 92–184
It does not answer the call, verify audio, exercise people-card home-id routing, or prove one DM thread.
The offline DM test proves only local QUEUED state:
dm-flow.spec.ts, lines 15–36
It never reconnects or proves recipient delivery.
The mesh helper permits fewer than the calculated expected connections:
signal-manager.ts, lines 28–50
This can let a partial dual-signal mesh pass. Debug inspection also depends on Angular's dev-only window.ng.
The data-channel test proves that audio eventually resumes after a synthetic close:
data-channel-recovery.spec.ts, lines 27–54
It does not prove uninterrupted audio, which is consistent with the current full peer teardown.
Revised implementation order
Do not tune codecs, ICE timers, or TURN before identity and membership are correct.
Packet 0 — Reproduction and observability
Goal: produce one deterministic two-signal matrix and enough correlated data to prove each later fix.
Log in debug builds:
- home user id;
- credential actor id per normalized signal URL;
- room id/source URL;
- peer-map key and peer signal URL;
- initiator/polite decision inputs;
- call
targetUserIdand delivery outcome; - DM incoming and resolved conversation ids;
- data-channel generation and reconnect attempt reason.
Do not log tokens, passwords, provision secrets, SDP bodies, or message contents.
Packet 1 — Restore-safe silent foreign auth
Primary files:
toju-app/src/app/store/users/users.effects.tstoju-app/src/app/domains/authentication/application/services/signal-server-auth.service.tssignal-server-authorize.service.ts- provision-secret stores; targeted Electron bridge only if the persistence contract changes
Tests:
- restored home session + absent secret;
- restored Electron session;
- web new-tab behavior;
- old foreign account whose previous secret is lost;
- offline/unknown endpoint never opens authorize;
- foreign
auth_errordoes not expire the valid home session.
Packet 2 — Identify/join/presence integrity
Primary files:
room-signaling-connection.tssignaling-transport-handler.tssignaling.manager.ts- targeted
server/src/websocket/handler.tsonly if a failing test proves a server change is needed
Tests:
- credential-store fallback on a fresh socket;
- identify is first authenticated message before join;
- signal-server restart restores both users to the roster;
- leaving one signal scope does not remove a peer shared through another.
Packet 3 — Cross-signal identity for calls and DMs
Primary files:
domains/direct-message/**domains/direct-call/**peer-delivery.service.ts- authentication credential/alias readers
Tests:
- candidate collection from home id, actor id, peer id, and known presence routes;
- unreachable call never leaves caller silently in voice;
- merge two alias conversations without message/status loss;
- cross-signal DM reply remains in one thread;
- cross-signal call rings, answers, and carries bidirectional audio.
Packet 4 — Per-signal WebRTC identity
Primary files:
realtime-session.service.tssignaling-message-handler.tsconnection/negotiation.tsrecovery/peer-recovery.ts- voice-state data-channel payload construction
Tests:
- home-id/actor-id matrix elects exactly one initiator;
- polite-peer collision decision uses the same scoped ids;
- same-home and cross-home voice are bidirectional;
- stale voice state cannot leave a connected peer permanently muted without recovery.
Packet 5 — Honest data-channel and reconnect recovery
Primary files:
recovery/peer-recovery.tsmessaging/data-channel.tspeer-connection.manager.ts- user-visible connectivity state/components
Tests:
- signaling-down ticks do not consume reconnect attempts;
- max real attempts exposes Retry;
- Retry re-arms recovery;
- data-channel reopen forces inventory;
- media state is reapplied after full rebuild;
- if soft replacement is implemented, audio remains continuously flowing.
Packet 6 — Message convergence and file integrity
Primary files:
store/messages/messages-sync.effects.ts- message sync reducers/rules/handlers
- attachment transfer only where a failing test identifies a gap
Tests:
- timeout remains dirty;
- only complete, empty inventory rounds select the 15-minute cadence;
- late join catches up;
- edit/delete converge after DC recovery;
- offline DM reconnects and delivers;
- large-file download SHA-256 and size match the source;
- attachment announce/message order is stressed in both channel and DM paths.
Packet 7 — Production reliability proof
Add separate suites rather than weakening existing fast PR coverage:
- Electron smoke: login restore, foreign provision, text, file, voice;
- signal restart during active voice and DM;
- TURN-backed relay-only test;
- sleep/wake or network-interface-change test;
- 30-minute scheduled voice/screen-share soak;
- exact mesh assertions where topology guarantees the count.
Documentation corrections required with fixes
- Replace the false soft-data-channel paragraph in
realtime/README.mdunless soft replacement ships. - Relabel the LESSONS outbound-call and DM-canonicalization entries as unfinished specifications until real symbols and tests exist.
- Correct authentication README endpoints from
/api/auth/*to/api/users/*. - Correct chat README inventory cap from 1,000 to 20,000.
- Mark
shared-kernel/signaling-contracts.tsnon-authoritative or align it with live wire names. - Correct
e2e/CONTEXT.mdif it claims Electron coverage; the current suite is browser +ng serve.
Decisions required from the product owner before implementation
- Lost provision secret recovery
- Recommended: automatically create a secret when none has ever existed; when an existing foreign account rejects the new secret, show a targeted per-server recovery action instead of a generic login loop.
- Web secret durability
- Recommended emergency default: keep session-scoped storage for security, but make the new-tab limitation explicit and recover without misclassifying home logout.
- Data-channel strategy
- Recommended: ship visible full-rebuild recovery + forced resync first, then soft replacement as a measured follow-up.
- TURN
- Recommended for the stated Discord-quality goal: managed credentialed TURN, not user-only configuration.
- History availability
- The signal server stores no chat history. Decide whether “offline users always recover history” may depend on another online client, or whether an encrypted durable mailbox/history service is required.
- Presence semantics
- Define online/idle/DND/offline and stale timeout behavior. Current emergency tests mostly prove connectivity, not Discord-like status semantics.
Definition of done for Fable 5
For every packet:
- Start from a behavior-level failing test or deterministic reproduction.
- Change only the packet's owned surfaces.
- Record actor ids and signal scope in test diagnostics without secrets.
- Run focused unit/integration tests, then the relevant Playwright subset.
- Run lint/build for touched packages.
- Update lying docs in the same change.
- Do not mark complete from unit-green alone; attach the user-visible proof.
The green 65-test archive is a useful baseline, not release certification. Release confidence requires the two-signal cross-home matrix, real restart recovery, Electron coverage, and TURN/NAT validation described above.