PoC: auth conn lost after a flap during AUTH_SYNC (fresh-create-community) - #13
Open
holmesworcester wants to merge 1 commit into
Open
PoC: auth conn lost after a flap during AUTH_SYNC (fresh-create-community)#13holmesworcester wants to merge 1 commit into
holmesworcester wants to merge 1 commit into
Conversation
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>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_COMMUNITYon QSS (qssSetupflips 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 betweenSIGN_IN_COMMUNITYand 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:
QSSAuthConnection._onQssDisconnected(qss-auth-conn.ts:70-73) marks the conn INACTIVE.QSSAuthConnectionManager._handleQssClientDisconnected(qss-auth-conn-manager.service.ts:70-73) callsclose(false), which iterates the map and callsstopConnection(line 147-155) —this.authConnMap.delete(teamId). Every team's auth-conn entry is wiped from the map.When the websocket reconnects:
QSSService._handleQssConnected(qss.service.ts:199-202) emitsQSS_HANDLE_SIGN_INonce._handleQssHandleSignIn(qss.service.ts:217-254) acquires_signInMutex, seesqssSetup=true, takes the sign-in branch, calls_signInToCommunityImpl— which sendsSIGN_IN_COMMUNITYwith a 5 s ack timeout and then callsqssAuthConnManager.startNewConnection.If that one sign-in attempt fails — either:
SIGN_IN_COMMUNITYack times out (5 s) under tail latency, orif (!this.connected)check and thestartNewConnectioncall —then
_signInToCommunityImplreturns ERROR with no retry queued. The websocket is connected (_scheduleReconnectdoesn't fire),QSS_CONNECTEDwon't re-fire (we're already connected), soQSS_HANDLE_SIGN_INwon'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
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=333chaos profile, instruments the auth-conn lifecycle, then assertsqssAuthConnManager.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. IfqssSetup === trueandqssAuthConnManager.getConnection(teamId)is undefined whenQSS_CONNECTEDfires (or shortly after), re-emitQSS_HANDLE_SIGN_INafter a short backoff so the sign-in retries.Option B — retry-on-ERROR in
_handleQssHandleSignIn. When_signInToCommunityImplreturns ERROR (andqssSetupis still true), schedule one moreQSS_HANDLE_SIGN_INemit afterQSS_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.tsRequires a local QSS server (
npm run start:qss) and a Toxiproxy proxy (npm run start:qss:toxi). Node 20.20.1 for backend tests.