diff --git a/broadcaster.go b/broadcaster.go index ebcbf6e..eb7539b 100644 --- a/broadcaster.go +++ b/broadcaster.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "slices" "strconv" "strings" "sync" @@ -66,6 +67,8 @@ type Broadcaster struct { galleryMu sync.Mutex started bool acceptWg sync.WaitGroup + // recreateFailures counts consecutive recreateSession failures; guarded by mu. + recreateFailures int // lastQuery is the most recent successful target-server query, kept so // query failures fall back to real data instead of failing the update. @@ -1831,11 +1834,58 @@ func retryWithBackoff(ctx context.Context, base, max time.Duration, attempt func } } -// recreateSession tears down and rebuilds signaling, session, and listener after a drop. +// recreateSession tears down and rebuilds signaling, session, and listener +// after a drop. Once a rebuild has already failed, the next one first discards +// the primary Xbox Live client: a client whose RTA connection is gone fails +// every publish the same way, and only a fresh client recovers. func (b *Broadcaster) recreateSession() error { b.mu.Lock() defer b.mu.Unlock() + if b.recreateFailures > 0 { + b.discardPrimaryXBLClientLocked() + } + if err := b.recreateSessionLocked(); err != nil { + b.recreateFailures++ + return err + } + b.recreateFailures = 0 + return nil +} + +// closeXBLClient is a test seam for discarding a created client. +var closeXBLClient = func(ctx context.Context, client *xsapi.Client) error { + return client.CloseContext(ctx) +} + +// discardPrimaryXBLClientLocked closes and forgets the primary Xbox Live +// client when the broadcaster created it, so the next rebuild dials a new one. +// A caller-owned client is left alone. +func (b *Broadcaster) discardPrimaryXBLClientLocked() { + client := b.conf.XBLClient + if client == nil { + client = b.xblClient + } + if client == nil { + return + } + created := createdXBLClientSet(b.createdXBLClients) + if !xblClientCreated(client, created) { + b.debug("primary xbox live client is caller-owned; keeping it across the re-create") + return + } + b.warn("discarding primary xbox live client after a failed session re-create") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := closeXBLClient(ctx, client); err != nil { + b.debug("close discarded xbox live client", "err", err) + } + b.clearCreatedXBLClientReferences(map[*xsapi.Client]struct{}{client: {}}) + b.createdXBLClients = slices.DeleteFunc(b.createdXBLClients, func(c *xsapi.Client) bool { return c == client }) +} + +// recreateSessionLocked does the rebuild; the caller holds mu. +func (b *Broadcaster) recreateSessionLocked() error { if b.ctx.Err() != nil || !b.started { return errors.New("broadcaster is shut down") } diff --git a/broadcaster_test.go b/broadcaster_test.go index d0937d5..06c0e0b 100644 --- a/broadcaster_test.go +++ b/broadcaster_test.go @@ -984,3 +984,61 @@ func TestStartSubAccountsTimeoutDoesNotBlockStartup(t *testing.T) { t.Fatal("startSubAccounts blocked on a hung sub-account publish") } } + +// A rebuild that fails twice in a row discards the client the broadcaster +// created, so the third attempt dials Xbox Live afresh instead of reusing a +// dead RTA connection. A caller-owned client is never touched. +func TestRecreateSessionDiscardsCreatedPrimaryClientAfterRepeatedFailure(t *testing.T) { + closed := 0 + oldClose := closeXBLClient + closeXBLClient = func(context.Context, *xsapi.Client) error { + closed++ + return nil + } + t.Cleanup(func() { closeXBLClient = oldClose }) + + newBroadcaster := func(primary *xsapi.Client, created bool) *Broadcaster { + b := &Broadcaster{ + ctx: context.Background(), + started: true, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + conf: Config{ + XBLClient: primary, + SignalingFactory: func(context.Context, Config) (nethernet.Signaling, error) { + return nil, errors.New("signaling down") + }, + }, + } + if created { + b.xblClient = primary + b.createdXBLClients = []*xsapi.Client{primary} + } + return b + } + + primary := &xsapi.Client{} + b := newBroadcaster(primary, true) + if err := b.recreateSession(); err == nil { + t.Fatal("first recreateSession succeeded unexpectedly") + } + if closed != 0 || b.conf.XBLClient != primary { + t.Fatalf("first failure discarded the client: closed=%d client=%v", closed, b.conf.XBLClient) + } + if err := b.recreateSession(); err == nil { + t.Fatal("second recreateSession succeeded unexpectedly") + } + if closed != 1 { + t.Fatalf("closed = %d, want 1 after the second consecutive failure", closed) + } + if b.conf.XBLClient != nil || b.xblClient != nil || len(b.createdXBLClients) != 0 { + t.Fatalf("created client was not forgotten: conf=%v cached=%v created=%d", b.conf.XBLClient, b.xblClient, len(b.createdXBLClients)) + } + + external := &xsapi.Client{} + b = newBroadcaster(external, false) + _ = b.recreateSession() + _ = b.recreateSession() + if closed != 1 || b.conf.XBLClient != external { + t.Fatalf("caller-owned client was discarded: closed=%d client=%v", closed, b.conf.XBLClient) + } +} diff --git a/go.mod b/go.mod index ead0a75..79c83d6 100644 --- a/go.mod +++ b/go.mod @@ -57,4 +57,4 @@ replace ( github.com/sandertv/gophertunnel => github.com/hashimthearab/gophertunnel v1.25.3-0.20260826204037-503152e50a95 ) -replace github.com/df-mc/go-xsapi/v2 => github.com/HashimTheArab/go-xsapi/v2 v2.0.0-20260815130220-1dd83707307e +replace github.com/df-mc/go-xsapi/v2 => github.com/HashimTheArab/go-xsapi/v2 v2.0.0-20260902164153-1a6c6ced1f96 diff --git a/go.sum b/go.sum index a7bba80..7388485 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/HashimTheArab/go-xsapi/v2 v2.0.0-20260815130220-1dd83707307e h1:2zKmw8RzU0+YL04O8QTJGkhejgxd+Lnu+rQfoGjTsQ0= -github.com/HashimTheArab/go-xsapi/v2 v2.0.0-20260815130220-1dd83707307e/go.mod h1:Gi/zQG2DFMJOMt4DIjuBuINTnU4YnZINe+RyFyza8oo= +github.com/HashimTheArab/go-xsapi/v2 v2.0.0-20260902164153-1a6c6ced1f96 h1:W2Tz8l9B3PNwSYNyIL/bCk1mSEHkM8u8ktqoNTGUdMA= +github.com/HashimTheArab/go-xsapi/v2 v2.0.0-20260902164153-1a6c6ced1f96/go.mod h1:Gi/zQG2DFMJOMt4DIjuBuINTnU4YnZINe+RyFyza8oo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=