Skip to content

refactor(gregor): connection identity, own ctx, and app-state gating - #29659

Open
chrisnojima wants to merge 5 commits into
masterfrom
nojima/HOTPOT-as-gregor
Open

chrisnojima wants to merge 5 commits into
masterfrom
nojima/HOTPOT-as-gregor

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Split out of #29637 so the gregor connection rework can be reviewed on its own. Based on master, independent of the app-state stack.

Why

The handler tracked "which connection is the current one" out of band, in fields beside the connection rather than on it. Every asynchronous step afterwards — the OnConnect tail, the ping loop, the push-state debouncer, installing the gregor client — then had to re-derive whether it was still relevant, and several did not. The fix is to give each connection its own ctx and gate the work on connection identity, so a superseded connection's work simply stops.

Master bugs this fixes

  • A stale connection can install a gregor client over a live one. OnConnect is not gated on connection identity, so a connect that lost the race still completes its tail.
  • Goroutines outlive their connection. The ping loop and push-state debouncer select on a shared shutdown channel, not the connection's, so they keep running against a replaced connection.
  • A failed connect still starts goroutines, leaving work running for a connection that never came up.
  • connTransport has a data raceconn, transport and stagedTransport are read and written without synchronisation.
  • Gregor stays connected in BACKGROUND and is taken down on INACTIVE, which is backwards: INACTIVE is a transient UI state (Control Center, app switcher) where the connection should be kept, and BACKGROUND is where it should go away.
  • Reconnect blocks its caller while dialling.
  • Syncer.Connected runs against a cancelled ctx instead of bailing.
  • A shut-down connection can mark chat offline. The rpc Connection has no shutdown flag, so any later call on a replaced connection starts a new reconnect loop, whose OnDisconnected marks the syncer offline and clears connectedAt while the current connection is up.
  • A reconnect request can tear down a newer connection. A ping timeout on one connection that is serviced after it has been replaced shuts down the healthy replacement.
  • The ping goroutine can leak on shutdown: it does a blocking send on an unbuffered channel nobody reads any more.
  • A replay queued before a logout still runs the in-band handlers for the logged-out user.
  • The attachment HTTP server stops on INACTIVE, the same transient state as above.

What this changes

Each connection carries its own ctx, cancelled when that connection shuts down. OnConnect is gated on connection identity. connectTLS/connectNoTLS return the connection instead of assigning it and spawning goroutines, and a failed connect starts none. All connect decisions move into one reconciler: only BACKGROUND, or a desktop suspend, takes the connection down; INACTIVE keeps it up. Reconnect becomes asynchronous, and the connect tail is serialised with shutdown on one mutex.

Each connection gets its own rpc.ConnectionHandler holding that connection's ctx, so OnDisconnected and OnConnectError from a replaced connection are ignored. Reconnect requests carry the ctx of the connection they are for and are skipped once it is replaced. A login resets only an existing connection, so logging in while in BACKGROUND no longer drops the gregor client until the next foreground. A queued replay is skipped once its user has logged out. The attachment HTTP server now also stops only on BACKGROUND.

Ordering

Independent of the app-state stack at compile level — it consumes only MobileAppState.State()/NextUpdate() and DesktopAppState.Suspended()/NextSuspendUpdate(), all unchanged on master.

But it should land before or with #29651. That PR makes Android boot in BACKGROUND, and master's gregor.go:275 seeds its loop with a hardcoded MobileAppState_FOREGROUND — so after #29651 the loop wakes immediately at boot, reads BACKGROUND and disconnects. This PR is what makes that seed correct. Landing it after #29651 leaves a window where Android boots with gregor down.

Touches go/chat/types/interfaces.go, go/service/main.go and go/kbhttp/manager/manager.go, which the stack also touches in different hunks — a trivial rebase for whichever merges second.

Verification

go build, go vet, gofmt clean, golangci-lint --new-from-rev master 0 issues. 34 gregor connection tests and the HTTP server app-state test pass, including under -race. The tests for the replaced-connection, reconnect, replay and HTTP server guards each fail with their guard removed.

Not verified here: TestGregorHandler, TestGregorBadgesIBM, TestGregorTeamBadges, TestGregorBadgesOOBM and most TestSyncer* need kbweb on localhost:3000, which was not available. They are untouched by this change; CI is the first real check on them.

TestGregorConnScenarioReplay is deliberately omitted — it needs the lifecycle/lifecycletest package, which lands in #29651. Add it once both are in.

The gregor handler had no way to tell one connection from the next. A
late callback from a torn-down connection -- OnConnect finishing its tail,
a Syncer.Connected landing after Disconnected, a reconnect fired by a
connection that had already shut down -- would act on whatever connection
happened to be current. Shutdown used the caller's ctx, so work started by
a connection outlived it.

Every connection now has an identity and a ctx of its own. OnConnect's
post-sync steps run only while their connection is still the current one,
and report "no longer current" back to the caller so onConnectSynced can
return ErrDuplicateConnection instead of installing state for a dead
connection. Shutdown cancels that connection's ctx, so its in-flight work
stops with it rather than with whoever called Shutdown. Syncer.Connected
bails when its ctx is already cancelled, so it cannot mark the syncer
connected after the matching Disconnected.

Connecting is gated on app state in one place, gregorConnGate: only
BACKGROUND, or a desktop suspend, takes the connection down; INACTIVE
keeps it up. A BACKGROUND that lands after a connect has read the state
wakes the monitor, which waits for that connect to finish before taking
the connection down, so a connect and a state change cannot cross. The
service asks for ConnectFresh(uri) rather than doing its own
IsConnected()/Reset() dance, and OnLogout disconnects instead of
resetting.

ServerConnection.Reconnect no longer returns anything: callers never
waited for the result, and the value it returned described a connection
that could already be gone.

connTransport guards conn/transport/stagedTransport with a mutex -- the
connection dials on its own goroutine while Shutdown closes the transport.
OnDisconnected and OnConnectError now go through a per-connection handler
that ignores events from a connection already shut down. Reconnect requests
carry the ctx of the connection they are for and are skipped once it is
replaced. pingOnce no longer leaks its goroutine on shutdown, a queued replay
is skipped once its user logs out, and the ping redial test bounds elapsed
time instead of counting in a fixed window.
With no connection, as in BACKGROUND, a reset only dropped the gregor client
until the next connect.
Only BACKGROUND stops it, matching gregor. INACTIVE is transient on iOS
(control center, the app switcher, an incoming call).
OnConnect's gated steps go through connGate.runIfLive, which also hands over
the uri. Drop the non-resetting Connect, which only tests used.
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