EEBus: fix deadlock on save after validate (#29119)

This commit is contained in:
andig 2026-04-14 19:14:03 +02:00 • committed by GitHub
parent d50e6be468
commit 0f65fd960f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 7 deletions

View file

@ -237,17 +237,21 @@ func (c *EEBus) UnregisterDevice(ski string, device Device) {
c.log.TRACE.Printf("unregistering ski: %s", ski)
c.mux.Lock()
defer c.mux.Unlock()
if idx := slices.Index(c.clients[ski], device); idx != -1 {
c.clients[ski] = slices.Delete(c.clients[ski], idx, idx+1)
}
// only tear down SHIP connection when no more clients need it
if len(c.clients[ski]) == 0 {
delete(c.clients, ski)
c.service.UnregisterRemoteSKI(ski)
if len(c.clients[ski]) == 0 {
delete(c.clients, ski)
// Tear down the SHIP session after releasing the mutex: ship-go's CloseConnection
// on a non-Complete state synchronously invokes HandleConnectionClosed,
// which calls back into evcc's connect(ski, false) — and that needs to
// acquire c.mux. Holding c.mux across this cross-layer call would
// deadlock the same goroutine on its own non-reentrant mutex. See #28942.
defer c.service.UnregisterRemoteSKI(ski)
}
}
c.mux.Unlock()
}
func (c *EEBus) CustomerEnergyManagement() *CustomerEnergyManagement {

View file

@ -2,7 +2,12 @@ package eebus
import (
"testing"
"time"
eebusapi "github.com/enbility/eebus-go/api"
eebusmocks "github.com/enbility/eebus-go/mocks"
spineapi "github.com/enbility/spine-go/api"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
@ -23,3 +28,50 @@ certificate:
var res Config
require.NoError(t, yaml.Unmarshal([]byte(conf), &res))
}
// mockDevice implements Device for testing
type mockDevice struct{}
func (d *mockDevice) Connect(connected bool) {}
func (d *mockDevice) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) {
}
var _ Device = (*mockDevice)(nil)
// TestUnregisterDevice_MutexNotHeldDuringShipCall is the regression guard
// for issue #28942. It asserts that c.mux is NOT held at the point
// UnregisterRemoteSKI is called. The pre-fix code held c.mux across that
// cross-layer call, and ship-go's synchronous HandleConnectionClosed
// callback chain re-entered connect(ski, false) on the same goroutine,
// which then deadlocked on c.mux.Lock() (Go mutexes are non-reentrant).
//
// The assertion uses a goroutine that tries to briefly acquire c.mux from
// inside the mock's UnregisterRemoteSKI implementation; if the lock is
// held, the acquisition times out and the test fails.
func TestUnregisterDevice_MutexNotHeldDuringShipCall(t *testing.T) {
dev := &mockDevice{}
c := &EEBus{
log: util.NewLogger("test"),
clients: map[string][]Device{"aabbcc": {dev}},
}
service := eebusmocks.NewServiceInterface(t)
service.EXPECT().UnregisterRemoteSKI("aabbcc").Run(func(string) {
acquired := make(chan struct{})
go func() {
c.mux.Lock()
defer c.mux.Unlock()
close(acquired)
}()
select {
case <-acquired:
// good — mutex was free
case <-time.After(100 * time.Millisecond):
t.Errorf("c.mux was held while UnregisterRemoteSKI was called — " +
"regression to the cross-layer lock hold that caused #28942")
}
}).Once()
c.service = service
c.UnregisterDevice("aabbcc", dev)
}