From 3658882bc611b66b62821dafe5a29dcc4f4abd1f Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 02:16:58 +0800 Subject: [PATCH] fix(server): avoid deadlock when changing equipped main options --- go/internal/server/player/equipment.go | 11 +++- .../player/equipment_option_reroll_test.go | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/go/internal/server/player/equipment.go b/go/internal/server/player/equipment.go index 4d4e1e1..6b7a726 100644 --- a/go/internal/server/player/equipment.go +++ b/go/internal/server/player/equipment.go @@ -670,7 +670,12 @@ func (s *EquipmentInventory) mainOptionChange(request []byte) (int, []byte, bool } s.mu.Lock() - defer s.mu.Unlock() + locked := true + defer func() { + if locked { + s.mu.Unlock() + } + }() if s.optionReroll == nil { return 0, nil, true, errors.New("player: equipment option design unavailable") } @@ -709,6 +714,10 @@ func (s *EquipmentInventory) mainOptionChange(request []byte) (int, []byte, bool if pendingDirty { s.pendingReroll = pending } + // Character health reads account equipment through pictorial ownership. + // Release the equipment lock before resolving the equipped character. + s.mu.Unlock() + locked = false var response []byte if character, ok := s.equippedCharacter(current); ok { response = wire.AppendBytes(response, 1, CharacterWire(character)) diff --git a/go/internal/server/player/equipment_option_reroll_test.go b/go/internal/server/player/equipment_option_reroll_test.go index b7ca0f6..950106b 100644 --- a/go/internal/server/player/equipment_option_reroll_test.go +++ b/go/internal/server/player/equipment_option_reroll_test.go @@ -1,9 +1,11 @@ package player import ( + "fmt" "path/filepath" "reflect" "testing" + "time" "bd2server/internal/server/gamedata" "bd2server/internal/server/wire" @@ -227,6 +229,54 @@ func TestEquipmentMainOptionChangeRejectsForgedChoice(t *testing.T) { } } +func TestEquipmentMainOptionChangeEquippedCharacterHealthReadsEquipment(t *testing.T) { + fixture := newEquipmentOptionRerollFixture(t) + const characterIndex = 920000001 + characters := &CharacterStore{characters: []Character{{InvenIndex: characterIndex, ID: 50, Level: 1}}} + if err := characters.AttachMaxHealth(func(Character) (uint64, error) { + // The production max-health provider obtains pictorial equipment via All. + if got := fixture.equipment.All()[0].MainOption[0].ID; got != 9 { + return 0, fmt.Errorf("health provider observed stale main option %d", got) + } + return 123, nil + }); err != nil { + t.Fatal(err) + } + fixture.equipment.characters = characters + fixture.equipment.owned.Equipment[0].UseChar = characterIndex + request := wire.AppendVarint(nil, 1, 60) + request = wire.AppendVarint(request, 2, fixture.original.InvenIndex) + request = wire.AppendVarint(request, 3, 100) + request = wire.AppendVarint(request, 4, 9) + type result struct { + code int + body []byte + handled bool + err error + } + done := make(chan result, 1) + go func() { + code, body, handled, err := fixture.equipment.Handle("/EquipMainOptChange", request) + done <- result{code, body, handled, err} + }() + select { + case got := <-done: + if got.err != nil || !got.handled || got.code != 537 { + t.Fatalf("change result=%+v", got) + } + character, found, err := wire.Bytes(got.body, 1) + if err != nil || !found || len(character) == 0 { + t.Fatalf("equipped character response missing: found=%v err=%v", found, err) + } + hp, found, err := wire.Varint(character, 3) + if err != nil || !found || hp != 123 { + t.Fatalf("updated health=%d found=%v err=%v", hp, found, err) + } + case <-time.After(2 * time.Second): + t.Fatal("equipped main option change deadlocked while resolving character health") + } +} + func TestEquipmentOptionRerollRejectsForgedMaterialsWithoutCharge(t *testing.T) { fixture := newEquipmentOptionRerollFixture(t) fixture.equipment.BeginSession("option-reroll-forged-material")