Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion broadcaster.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Expand Down
58 changes: 58 additions & 0 deletions broadcaster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down