Skip to content

PoC: auth conn lost after a flap during AUTH_SYNC (fresh-create-community) - #13

Open
holmesworcester wants to merge 1 commit into
7.1.0from
poc/qss-auth-conn-lost-after-flap
Open

PoC: auth conn lost after a flap during AUTH_SYNC (fresh-create-community)#13
holmesworcester wants to merge 1 commit into
7.1.0from
poc/qss-auth-conn-lost-after-flap

Conversation

@holmesworcester

@holmesworcester holmesworcester commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

This is a real bug in 7.1.0's production code (packages/backend/src/nest/qss/), not a flaky test or harness artefact.

A community owner finishes CREATE_COMMUNITY on QSS (qssSetup flips true on disk) and then the LFA auth handshake never completes — the create-community screen sits forever, even after the network steadies. The same code path runs for a member during invite redemption, so either side is exposed during the brief window between SIGN_IN_COMMUNITY and the LFA handshake completing.

The deterministic test in this PR fails roughly half the time against unmodified 7.1.0. The remaining halves where it passes confirm the underlying code path can succeed when timing happens to favour it — this is a race in the recovery path, not a hard wedge that needs a particular toxic to surface.

What the bug does (purely production code, no test logic involved)

When the QSS websocket disconnects mid-handshake:

  1. QSSAuthConnection._onQssDisconnected (qss-auth-conn.ts:70-73) marks the conn INACTIVE.
  2. QSSAuthConnectionManager._handleQssClientDisconnected (qss-auth-conn-manager.service.ts:70-73) calls close(false), which iterates the map and calls stopConnection (line 147-155)this.authConnMap.delete(teamId). Every team's auth-conn entry is wiped from the map.

When the websocket reconnects:

  1. QSSService._handleQssConnected (qss.service.ts:199-202) emits QSS_HANDLE_SIGN_IN once.
  2. _handleQssHandleSignIn (qss.service.ts:217-254) acquires _signInMutex, sees qssSetup=true, takes the sign-in branch, calls _signInToCommunityImpl — which sends SIGN_IN_COMMUNITY with a 5 s ack timeout and then calls qssAuthConnManager.startNewConnection.

If that one sign-in attempt fails — either:

  • (a) the SIGN_IN_COMMUNITY ack times out (5 s) under tail latency, or
  • (b) the socket bounces between the if (!this.connected) check and the startNewConnection call —

then _signInToCommunityImpl returns ERROR with no retry queued. The websocket is connected (_scheduleReconnect doesn't fire), QSS_CONNECTED won't re-fire (we're already connected), so QSS_HANDLE_SIGN_IN won't either. No code path re-creates the auth conn. The user is stuck.

This is the same architectural shape as PRs #11 and #14: a single auto-flow attempt with no recovery path on a transient failure.

Failure trace from a failing run

+22439ms  startAuthConn-emitted    <teamId>     // qss.service.ts:777, end of _createCommunityImpl
+22439ms  auth-conn-set            <teamId>     // qss-auth-conn-manager.service.ts:137
+22445ms  startNewConnection-ok    <teamId>     // LFA handshake in flight
+22446ms  qss-disconnected         qssSetup=true; flap begins
+22450ms  auth-conn-deleted        <teamId>     // close(false) wipes the entry
+23220ms  qss-connected
+23234ms  startNewConnection-called             // sign-in branch re-runs
+23234ms  auth-conn-set
+23237ms  startNewConnection-ok
+23247ms  auth-conn-deleted                     // next flap-down deletes again
... (repeats per flap cycle) ...
+27000ms  qss-connected           (flap settles)
+27005ms  signIn-attempt-START
+32005ms  signIn-attempt-ERROR    SIGN_IN_COMMUNITY ack timed out
        ← nothing else fires; conn never re-created; test deadline elapses

What the test does

The test is a normal Jest spec that exercises the production code through real wire (Toxiproxy in the middle, real QSS server). It applies the seed=333 chaos profile, instruments the auth-conn lifecycle, then asserts qssAuthConnManager.getConnection(teamId) returns CONNECTED + JOINED within 60 s of the flap settling.

The test is not flaky in the "test bug" sense. It's deterministic in setup; the production code's behaviour under that setup is itself non-deterministic (the race window in step 4 above), which is why it fails ~50% rather than 100%. That's a property of the bug, not the test.

Suggested fix shapes

Two narrow options for the maintainer:

Option A — self-heal on _handleQssConnected. If qssSetup === true and qssAuthConnManager.getConnection(teamId) is undefined when QSS_CONNECTED fires (or shortly after), re-emit QSS_HANDLE_SIGN_IN after a short backoff so the sign-in retries.

Option B — retry-on-ERROR in _handleQssHandleSignIn. When _signInToCommunityImpl returns ERROR (and qssSetup is still true), schedule one more QSS_HANDLE_SIGN_IN emit after QSS_RECONNECT_DELAY_MS (or with exponential backoff). This generalises and would also paper over PR #11's stuck-state and PR #14's captcha-token-consumed issue.

Option B closes more bug surface for the same lines of code. Option A is a tighter, more local change. The maintainer should pick based on the codebase's invariants — this PR ships only the test, not the fix.

How to run the test

NODE_OPTIONS="--experimental-vm-modules" \
  ./node_modules/jest/bin/jest.js --config jest.stress.config.js \
  --runInBand --colors=false --forceExit \
  ./src/nest/qss/stress/scenarios/auth-conn-lost-after-flap.stress.spec.ts

Requires a local QSS server (npm run start:qss) and a Toxiproxy proxy (npm run start:qss:toxi). Node 20.20.1 for backend tests.

Reproduces a fuzz failure bucket discovered in a 50-seed sweep against
holmesworcester/quiet:7.1.0 of the fresh-create-community scenario:

  bucket "auth connection not present" — 4/50 occurrences
  smallest-seed repro: seed=333, profile random-seed-333
    toxics: latency 1429ms / jitter 1418ms (toxicity 0.626) +
            64 kbps downstream bandwidth
    outages: 2641 ms outage at preCreate
    flaps:   4628 ms of 395 ms-cycle on/off during duringAuthSync

User-visible symptom: a fresh community owner finishes CREATE_COMMUNITY
on QSS (qssSetup flips true on disk) and then the LFA auth handshake
never finishes — the create-community screen sits forever even after
the network steadies.

Failure path traced via instrumentation in the new spec:

  1. _createCommunityImpl (qss.service.ts:777) emits QSS_START_AUTH_CONN
     after qssSetup is set to true.
  2. The flap loop disconnects the QSS websocket. Each disconnect:
       - QSSAuthConnection._onQssDisconnected (qss-auth-conn.ts:70-73)
         stops the connection and marks it INACTIVE.
       - QSSAuthConnectionManager._handleQssClientDisconnected
         (qss-auth-conn-manager.service.ts:70-73) calls close(false)
         which deletes every entry from authConnMap.
  3. On every flap-up the QSS client reconnects, fires QSS_CONNECTED,
     and _handleQssHandleSignIn runs under _signInMutex. Because
     qssSetup is now true it takes the sign-in branch and calls
     _signInToCommunityImpl (qss.service.ts:936-986) which:
       a. sends SIGN_IN_COMMUNITY with a 5 s ack timeout, then
       b. calls startAuthConnection -> qssAuthConnManager.startNewConnection.
     Either step can fail under chaos: (a) can ack-timeout under
     latency+bandwidth, (b) throws "Must have an active QSS client
     socket prior to starting an auth connection!" if the next
     flap-down lands between the connectedness check and the start.
  4. When (a) or (b) fail, _signInToCommunityImpl returns ERROR. The
     mutex releases. There is no retry path inside the service: the
     only re-trigger of QSS_HANDLE_SIGN_IN is QSS_CONNECTED. If the
     flap settles with a single QSS_CONNECTED whose sign-in attempt
     fails this way, the auth conn is never re-created and the
     authConnMap entry stays absent.

The spec applies the seed=333 profile deterministically, instruments
the auth-conn lifecycle (startNewConnection success/error, authConnMap
set/delete, QSS_CONNECTED / QSS_DISCONNECTED, QSS_HANDLE_SIGN_IN), and
asserts the same invariant fuzz-create-community.stress.spec.ts:130-139
asserts: getConnection(teamId) reaches CONNECTED+JOINED within the
recovery window.
holmesworcester added a commit to TryQuiet/quiet that referenced this pull request May 7, 2026
A transient ERROR from signInToCommunity (5 s ack timeout under tail
latency, or the QSS websocket bouncing between checks) used to leave
the auto-flow stuck because QSS_CONNECTED is the only event that
re-emits QSS_HANDLE_SIGN_IN, and it does not re-fire while the
underlying websocket stays up. With qssSetup=true on disk, an
authConnMap entry that has been deleted by close(false) on a flap
disconnect is never re-created, so the LFA handshake never starts.

This breaks the loop with a single backoff-delayed retry scheduled
from inside _handleQssHandleSignIn:

  - On createCommunity returning false or signInToCommunity returning
    QSSOperationResult.ERROR, schedule one QSS_HANDLE_SIGN_IN re-emit
    after _signInRetryDelayMs (starts at QSS_RECONNECT_DELAY_MS = 50 ms,
    doubles up to QSS_RECONNECT_MAX_DELAY_MS = 60 s, matches the
    existing _scheduleReconnect cadence so a sustained failure climbs
    to the same cap instead of looping every 50 ms).
  - On a SUCCESS / true result the timer is cleared and the delay
    resets to its minimum.
  - The timer is also cleared on pause() and close() to avoid leaks.

Reproducer (full toxiproxy chaos harness lives on the fork):
  holmesworcester/quiet#13
  branch: holmesworcester:poc/qss-auth-conn-lost-after-flap

Adds a focused regression test in qss.service.spec.ts that mocks
signInToCommunity to return ERROR once then SUCCESS, calls connect()
to trigger the auto-flow, and waitForExpect()s a second invocation
within 5 s. Without the fix the spy is called once; with the fix it is
called twice after the ~50 ms backoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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