feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
@@ -51,10 +51,15 @@ const honeypotHeader = "X-Scrabble-Honeypot"
|
||||
// the edge can turn away a build too old to speak the current wire contract, before it decodes
|
||||
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
||||
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
|
||||
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
|
||||
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
|
||||
// on a served Execute response when the client is at or above the hard minimum but below the
|
||||
// recommended version, it nudges an update without failing the call. Headers are the
|
||||
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
|
||||
// contract (docs/ARCHITECTURE.md §2).
|
||||
const (
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
updateRecommendedHeader = "X-Update-Recommended"
|
||||
)
|
||||
|
||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||
@@ -104,6 +109,12 @@ type Server struct {
|
||||
minClient clientver.Version
|
||||
gateOn bool
|
||||
|
||||
// recClient is the version below which a served client is nudged to update (the soft tier);
|
||||
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
|
||||
// unparseable). It is independent of the hard gate.
|
||||
recClient clientver.Version
|
||||
recOn bool
|
||||
|
||||
publicPolicy ratelimit.Policy
|
||||
userPolicy ratelimit.Policy
|
||||
emailPolicy ratelimit.Policy
|
||||
@@ -148,6 +159,9 @@ type Deps struct {
|
||||
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
|
||||
// the gate dormant.
|
||||
MinClientVersion string
|
||||
// RecommendedClientVersion is the version below which a served client is nudged to update (the
|
||||
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
|
||||
RecommendedClientVersion string
|
||||
}
|
||||
|
||||
// NewServer constructs the edge service.
|
||||
@@ -192,6 +206,18 @@ func NewServer(d Deps) *Server {
|
||||
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
||||
}
|
||||
}
|
||||
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
|
||||
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
|
||||
// construction; an empty value leaves the soft tier dormant.
|
||||
var recClient clientver.Version
|
||||
recOn := false
|
||||
if d.RecommendedClientVersion != "" {
|
||||
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
|
||||
recClient, recOn = v, true
|
||||
} else {
|
||||
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
@@ -210,6 +236,8 @@ func NewServer(d Deps) *Server {
|
||||
maxBodyBytes: maxBody,
|
||||
minClient: minClient,
|
||||
gateOn: gateOn,
|
||||
recClient: recClient,
|
||||
recOn: recOn,
|
||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
||||
@@ -335,15 +363,44 @@ func (s *Server) clientTooOld(header string) bool {
|
||||
return clientver.Less(v, s.minClient)
|
||||
}
|
||||
|
||||
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
|
||||
// band — at or above the hard minimum but below the recommended version — so a served response can
|
||||
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
|
||||
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
|
||||
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
|
||||
// leaves minClient at the zero version, which no real version is below, so the band is simply
|
||||
// "below recommended").
|
||||
func (s *Server) clientUpdateRecommended(header string) bool {
|
||||
if !s.recOn {
|
||||
return false
|
||||
}
|
||||
v, ok := clientver.Parse(header)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
|
||||
}
|
||||
|
||||
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||
// session, unknown type, internal) become Connect errors.
|
||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
|
||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
|
||||
start := time.Now()
|
||||
msgType := req.Msg.GetMessageType()
|
||||
result := "internal"
|
||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
||||
|
||||
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
|
||||
// client at or above the hard minimum but below the recommended version is told an update is
|
||||
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
|
||||
// version yields no nudge.
|
||||
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
|
||||
defer func() {
|
||||
if recommend && resp != nil {
|
||||
resp.Header().Set(updateRecommendedHeader, "1")
|
||||
}
|
||||
}()
|
||||
|
||||
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
|
||||
// the payload is decoded, so a too-old client makes zero successful calls but sees the
|
||||
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
|
||||
|
||||
@@ -53,6 +53,33 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
|
||||
}
|
||||
}
|
||||
|
||||
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
|
||||
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||
t.Helper()
|
||||
backendSrv := httptest.NewServer(backendHandler)
|
||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: transcode.NewRegistry(backend, nil),
|
||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||
Limiter: ratelimit.New(),
|
||||
Hub: push.NewHub(0),
|
||||
RateLimit: config.DefaultRateLimit(),
|
||||
Heartbeat: 15 * time.Second,
|
||||
MinClientVersion: minVersion,
|
||||
RecommendedClientVersion: recommendedVersion,
|
||||
})
|
||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||
return client, func() {
|
||||
edgeSrv.Close()
|
||||
_ = backend.Close()
|
||||
backendSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGuestAuthOK(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
@@ -156,6 +183,54 @@ func TestExecuteVersionGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
|
||||
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
|
||||
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
|
||||
// and a dormant soft tier get no header.
|
||||
func TestExecuteUpdateRecommended(t *testing.T) {
|
||||
backend := func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
min string
|
||||
rec string
|
||||
header string
|
||||
wantResult string
|
||||
wantHeader bool
|
||||
}{
|
||||
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
|
||||
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
|
||||
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
|
||||
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
|
||||
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
|
||||
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
|
||||
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
|
||||
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
|
||||
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
|
||||
defer cleanup()
|
||||
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
|
||||
if tc.header != "" {
|
||||
req.Header().Set("X-Client-Version", tc.header)
|
||||
}
|
||||
resp, err := client.Execute(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
|
||||
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
|
||||
}
|
||||
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
|
||||
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
|
||||
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
|
||||
// fails Unauthenticated with no session, proving it passed the version gate).
|
||||
|
||||
Reference in New Issue
Block a user