diff --git a/go.mod b/go.mod index bacdfe1..47286be 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.10 require ( github.com/TeoSlayer/pilotprotocol v0.0.0 - github.com/pilot-protocol/common v0.1.0 + github.com/pilot-protocol/common v0.2.0 ) require ( @@ -14,3 +14,5 @@ require ( ) replace github.com/TeoSlayer/pilotprotocol => ../web4 + +replace github.com/pilot-protocol/common => ../common diff --git a/handshake.go b/handshake.go index b9695c1..d89d9cf 100644 --- a/handshake.go +++ b/handshake.go @@ -14,8 +14,8 @@ import ( "sync" "time" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" - "github.com/TeoSlayer/pilotprotocol/pkg/protocol" + "github.com/pilot-protocol/common/coreapi" + "github.com/pilot-protocol/common/protocol" "github.com/pilot-protocol/common/crypto" "github.com/pilot-protocol/common/fsutil" ) diff --git a/runtime.go b/runtime.go index 2b594db..3e3fa27 100644 --- a/runtime.go +++ b/runtime.go @@ -19,7 +19,7 @@ package handshake import ( "crypto/ed25519" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" + "github.com/pilot-protocol/common/coreapi" ) // Runtime is the primitives-only contract the handshake manager needs diff --git a/service.go b/service.go index a51dc5f..d68b1de 100644 --- a/service.go +++ b/service.go @@ -5,7 +5,7 @@ package handshake import ( "context" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" + "github.com/pilot-protocol/common/coreapi" ) // Service is the L11 plugin adapter for the manual trust-handshake diff --git a/zz_ceiling_fakes_test.go b/zz_ceiling_fakes_test.go index bfc88ef..17c1401 100644 --- a/zz_ceiling_fakes_test.go +++ b/zz_ceiling_fakes_test.go @@ -8,7 +8,7 @@ import ( "sync" "testing" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" + "github.com/pilot-protocol/common/coreapi" "github.com/TeoSlayer/pilotprotocol/pkg/daemon" "github.com/pilot-protocol/common/crypto" ) diff --git a/zz_ceiling_more_test.go b/zz_ceiling_more_test.go index 9566d47..bb221c7 100644 --- a/zz_ceiling_more_test.go +++ b/zz_ceiling_more_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" + "github.com/pilot-protocol/common/coreapi" ) // Additional ceiling tests for the small uncovered branches that fell out diff --git a/zz_handshake_connection_parse_test.go b/zz_handshake_connection_parse_test.go index 8d1c428..0237c41 100644 --- a/zz_handshake_connection_parse_test.go +++ b/zz_handshake_connection_parse_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" + "github.com/pilot-protocol/common/coreapi" ) // Iter-104 coverage for handleConnection (0% baseline) + processMessage diff --git a/zz_handshake_manager_bootstrap_test.go b/zz_handshake_manager_bootstrap_test.go index 30ccbf8..3c24023 100644 --- a/zz_handshake_manager_bootstrap_test.go +++ b/zz_handshake_manager_bootstrap_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/TeoSlayer/pilotprotocol/pkg/daemon" - "github.com/TeoSlayer/pilotprotocol/pkg/protocol" + "github.com/pilot-protocol/common/protocol" ) // Iter-114 coverage for Manager.Start — the port-444 service bootstrap diff --git a/zz_helpers_test.go b/zz_helpers_test.go index 2d9753b..8e58ce3 100644 --- a/zz_helpers_test.go +++ b/zz_helpers_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" + "github.com/pilot-protocol/common/coreapi" "github.com/TeoSlayer/pilotprotocol/pkg/daemon" - "github.com/TeoSlayer/pilotprotocol/pkg/protocol" - registryclient "github.com/TeoSlayer/pilotprotocol/pkg/registry/client" + "github.com/pilot-protocol/common/protocol" + registryclient "github.com/pilot-protocol/common/registry/client" "github.com/TeoSlayer/pilotprotocol/tests/regtestutil" "github.com/pilot-protocol/common/crypto" ) diff --git a/zz_pending_cleanup_test.go b/zz_pending_cleanup_test.go new file mode 100644 index 0000000..40f102f --- /dev/null +++ b/zz_pending_cleanup_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handshake + +// Regression for the stale pending/outgoing entries observed on +// 2026-05-26: processRelayedRequest's sameNetwork / trustedAgent / +// autoApprove branches (and symmetric paths in handleRequest) call +// markTrustedLocked without clearing hm.pending or hm.outgoing, so a +// peer who sent a direct request (recorded in pending) and then arrives +// via a relayed auto-approve path stays in pending forever — pilotctl +// surfaces it, the policy fill_trust loop could re-target it. +// +// markTrustedLocked now centralizes the cleanup. These tests pin the +// invariant: after any trust grant, the peer's pending + outgoing +// entries are gone. + +import ( + "testing" + "time" +) + +func TestMarkTrustedClearsPending(t *testing.T) { + t.Parallel() + + hm := &Manager{ + trusted: make(map[uint32]*TrustRecord), + pending: make(map[uint32]*PendingHandshake), + outgoing: make(map[uint32]time.Time), + revoked: make(map[uint32]time.Time), + replaySet: make(map[[32]byte]time.Time), + trustWaiters: make(map[uint32][]chan struct{}), + } + + const peer = uint32(7777) + hm.pending[peer] = &PendingHandshake{NodeID: peer, ReceivedAt: time.Now()} + + hm.mu.Lock() + hm.markTrustedLocked(peer, &TrustRecord{NodeID: peer, ApprovedAt: time.Now()}) + hm.mu.Unlock() + + if _, ok := hm.pending[peer]; ok { + t.Fatal("markTrustedLocked left a stale entry in hm.pending — " + + "would show in pilotctl pending forever") + } + if _, ok := hm.trusted[peer]; !ok { + t.Fatal("markTrustedLocked did not record trust") + } +} + +func TestMarkTrustedClearsOutgoing(t *testing.T) { + t.Parallel() + + hm := &Manager{ + trusted: make(map[uint32]*TrustRecord), + pending: make(map[uint32]*PendingHandshake), + outgoing: make(map[uint32]time.Time), + revoked: make(map[uint32]time.Time), + replaySet: make(map[[32]byte]time.Time), + trustWaiters: make(map[uint32][]chan struct{}), + } + + const peer = uint32(8888) + hm.outgoing[peer] = time.Now() + + hm.mu.Lock() + hm.markTrustedLocked(peer, &TrustRecord{NodeID: peer, ApprovedAt: time.Now()}) + hm.mu.Unlock() + + if _, ok := hm.outgoing[peer]; ok { + t.Fatal("markTrustedLocked left a stale entry in hm.outgoing — " + + "policy fill_trust could re-target this peer") + } +} + +func TestMarkTrustedClearsBothMaps(t *testing.T) { + t.Parallel() + + hm := &Manager{ + trusted: make(map[uint32]*TrustRecord), + pending: make(map[uint32]*PendingHandshake), + outgoing: make(map[uint32]time.Time), + revoked: make(map[uint32]time.Time), + replaySet: make(map[[32]byte]time.Time), + trustWaiters: make(map[uint32][]chan struct{}), + } + + const peer = uint32(9999) + hm.pending[peer] = &PendingHandshake{NodeID: peer, ReceivedAt: time.Now()} + hm.outgoing[peer] = time.Now() + + hm.mu.Lock() + hm.markTrustedLocked(peer, &TrustRecord{NodeID: peer, ApprovedAt: time.Now()}) + hm.mu.Unlock() + + if _, ok := hm.pending[peer]; ok { + t.Error("pending not cleared") + } + if _, ok := hm.outgoing[peer]; ok { + t.Error("outgoing not cleared") + } + if _, ok := hm.trusted[peer]; !ok { + t.Error("trusted not recorded") + } +} + +func TestMarkTrustedNoEntryIsIdempotent(t *testing.T) { + t.Parallel() + // delete() on a missing key must be a no-op so that callers which + // pre-delete (handleRequest mutual path, handleAccept) stay correct. + + hm := &Manager{ + trusted: make(map[uint32]*TrustRecord), + pending: make(map[uint32]*PendingHandshake), + outgoing: make(map[uint32]time.Time), + revoked: make(map[uint32]time.Time), + replaySet: make(map[[32]byte]time.Time), + trustWaiters: make(map[uint32][]chan struct{}), + } + + hm.mu.Lock() + hm.markTrustedLocked(1, &TrustRecord{NodeID: 1, ApprovedAt: time.Now()}) + hm.mu.Unlock() + + if _, ok := hm.trusted[1]; !ok { + t.Fatal("trust grant should not depend on pre-existing pending/outgoing") + } +} diff --git a/zz_persistence_test.go b/zz_persistence_test.go new file mode 100644 index 0000000..0e62ee3 --- /dev/null +++ b/zz_persistence_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handshake + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestSaveTrust_NoPathIsNoop(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") // empty storePath + hm.mu.Lock() + hm.trusted[1] = &TrustRecord{NodeID: 1, ApprovedAt: time.Now()} + hm.mu.Unlock() + hm.saveTrust() // must not panic +} + +func TestSaveTrust_WritesFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "trust.json") + hm := newTestHM(t, path) + + hm.mu.Lock() + hm.trusted[1] = &TrustRecord{NodeID: 1, ApprovedAt: time.Now(), Mutual: true} + hm.pending[2] = &PendingHandshake{NodeID: 2, ReceivedAt: time.Now()} + hm.mu.Unlock() + + hm.saveTrust() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if len(body) == 0 { + t.Error("trust file empty") + } +} + +func TestSaveTrust_MkdirError(t *testing.T) { + t.Parallel() + // Path under /dev/null can't have its parent dir created. + hm := newTestHM(t, "/dev/null/cannot/trust.json") + hm.saveTrust() // must not panic; logs error and returns +} + +func TestLoadTrust_NoPathIsNoop(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.loadTrust() // must not panic + if len(hm.trusted) != 0 { + t.Errorf("trusted len = %d, want 0", len(hm.trusted)) + } +} + +func TestLoadTrust_MissingFileIsNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + hm := newTestHM(t, filepath.Join(dir, "no-such.json")) + hm.loadTrust() + if len(hm.trusted) != 0 { + t.Errorf("trusted len = %d, want 0", len(hm.trusted)) + } +} + +func TestSaveLoadTrust_Roundtrip(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "trust.json") + hm := newTestHM(t, path) + + approvedAt := time.Now() + hm.mu.Lock() + hm.trusted[42] = &TrustRecord{ + NodeID: 42, + PublicKey: "pk", + ApprovedAt: approvedAt, + Mutual: true, + Network: 7, + } + hm.mu.Unlock() + hm.saveTrust() + + // Fresh manager loads the same file. + hm2 := newTestHM(t, path) + hm2.loadTrust() + hm2.mu.RLock() + defer hm2.mu.RUnlock() + rec, ok := hm2.trusted[42] + if !ok { + t.Fatal("trust record missing after roundtrip") + } + if rec.PublicKey != "pk" || rec.Network != 7 || !rec.Mutual { + t.Errorf("loaded = %+v", rec) + } +} diff --git a/zz_relayed_more_test.go b/zz_relayed_more_test.go new file mode 100644 index 0000000..96d8d8f --- /dev/null +++ b/zz_relayed_more_test.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handshake + +import ( + "testing" + "time" +) + +// TestProcessRelayedRequest_AlreadyTrustedIsAccept exercises the +// already-trusted fast path. +func TestProcessRelayedRequest_AlreadyTrustedIsAccept(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + // Seed: peer 42 is already trusted. + hm.mu.Lock() + hm.trusted[42] = &TrustRecord{NodeID: 42, ApprovedAt: time.Now()} + hm.mu.Unlock() + + // No registry → the "respond" branch quietly bails. + hm.ProcessRelayedRequest(42, "any justification") + // Just verify no panic + trust map unchanged. + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.trusted[42]; !ok { + t.Error("trust record should remain") + } +} + +// TestProcessRelayedRequest_MutualHandshakeAutoApproves drives the +// outgoing → mutual path. +func TestProcessRelayedRequest_MutualHandshakeAutoApproves(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + // Seed an outgoing request to peer 99. + hm.mu.Lock() + hm.outgoing[99] = time.Now() + hm.mu.Unlock() + + hm.ProcessRelayedRequest(99, "mutual please") + + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.trusted[99]; !ok { + t.Error("peer 99 should be trusted after mutual handshake") + } + if _, ok := hm.outgoing[99]; ok { + t.Error("outgoing entry should be cleared") + } +} + +// TestProcessRelayedRequest_NewRequestPending stages a pending request. +func TestProcessRelayedRequest_NewRequestPending(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.ProcessRelayedRequest(0x1234, "test justification") + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.pending[0x1234]; !ok { + t.Error("expected pending entry for 0x1234") + } + if _, ok := hm.trusted[0x1234]; ok { + t.Error("should not auto-trust without mutual/network") + } +} + +// TestProcessRelayedApproval_KnownOutgoingTrusts drives the +// outgoing → trusted approval path. +func TestProcessRelayedApproval_KnownOutgoingTrusts(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.mu.Lock() + hm.outgoing[55] = time.Now() + hm.mu.Unlock() + + hm.ProcessRelayedApproval(55) + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.trusted[55]; !ok { + t.Error("peer 55 should be trusted after approval") + } +} + +// TestProcessRelayedApproval_UnknownPeerNoPanic — auto-approve may +// create a trust record without an outgoing entry; the test only +// verifies no panic. +func TestProcessRelayedApproval_UnknownPeerNoPanic(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.ProcessRelayedApproval(0x9999) // no panic = pass +} + +// TestProcessRelayedRejection_RemovesOutgoing covers the rejection path. +func TestProcessRelayedRejection_RemovesOutgoing(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.mu.Lock() + hm.outgoing[77] = time.Now() + hm.mu.Unlock() + + hm.ProcessRelayedRejection(77) + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.outgoing[77]; ok { + t.Error("outgoing entry should be removed on rejection") + } +} + +// TestProcessRelayedRejection_UnknownPeerIsNoop covers the no-op branch. +func TestProcessRelayedRejection_UnknownPeerIsNoop(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.ProcessRelayedRejection(0x9999) // no panic +} + +// TestRejectHandshake_RemovesPending drives the local-reject path. +func TestRejectHandshake_RemovesPending(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.mu.Lock() + hm.pending[0xABCD] = &PendingHandshake{NodeID: 0xABCD} + hm.mu.Unlock() + + // Without registry, sendMessage will fail — that returns the + // pending-not-removed error path. So we set up testRuntime without + // a registry. Just verify the local rejection completes. + _ = hm.RejectHandshake(0xABCD, "no thanks") + // The pending entry should be cleared regardless of registry success. + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.pending[0xABCD]; ok { + t.Error("pending entry should be cleared after RejectHandshake") + } +} + +// TestRejectHandshake_UnknownPeerSafeNoop verifies the unknown-peer +// path doesn't panic (implementation tolerates the missing entry). +func TestRejectHandshake_UnknownPeerSafeNoop(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + _ = hm.RejectHandshake(0x9999, "n/a") // no panic = pass +} + +// TestRevokeTrust_RemovesTrustedPeer drives the trust-removal path. +func TestRevokeTrust_RemovesTrustedPeer(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.mu.Lock() + hm.trusted[0x1111] = &TrustRecord{NodeID: 0x1111} + hm.mu.Unlock() + + if err := hm.RevokeTrust(0x1111); err != nil { + t.Errorf("RevokeTrust: %v", err) + } + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.trusted[0x1111]; ok { + t.Error("trust entry should be removed") + } +} + +// TestRevokeTrust_UnknownPeerErrors covers the not-found branch. +func TestRevokeTrust_UnknownPeerErrors(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + if err := hm.RevokeTrust(0x9999); err == nil { + t.Error("expected error for unknown peer") + } +} + +// TestReapOutgoingAndRevoked_PrunesExpired drives the cleanup loop. +func TestReapOutgoingAndRevoked_PrunesExpired(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + hm.mu.Lock() + // Old outgoing — should be reaped. + hm.outgoing[10] = time.Now().Add(-2 * time.Hour) + // Fresh outgoing — should remain. + hm.outgoing[20] = time.Now() + hm.mu.Unlock() + + hm.reapOutgoingAndRevoked() + + hm.mu.RLock() + defer hm.mu.RUnlock() + if _, ok := hm.outgoing[10]; ok { + t.Error("old outgoing should be reaped") + } + if _, ok := hm.outgoing[20]; !ok { + t.Error("fresh outgoing should remain") + } +} diff --git a/zz_simple_test.go b/zz_simple_test.go new file mode 100644 index 0000000..9b808d6 --- /dev/null +++ b/zz_simple_test.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handshake + +import ( + "context" + "testing" + "time" + + "github.com/pilot-protocol/common/coreapi" +) + +func TestNewService_DefaultsAndAccessors(t *testing.T) { + t.Parallel() + rt := newTestRuntime() + svc := NewService(rt) + if svc == nil { + t.Fatal("NewService returned nil") + } + if svc.Name() != "handshake" { + t.Errorf("Name = %q, want handshake", svc.Name()) + } + if svc.Order() != 60 { + t.Errorf("Order = %d, want 60", svc.Order()) + } + if svc.Manager() == nil { + t.Error("Manager() returned nil") + } +} + +func TestService_StartFailsWithoutPortListener(t *testing.T) { + t.Parallel() + rt := newTestRuntime() + svc := NewService(rt) + // testRuntime.PortListener returns an error stub → Manager.Start should + // surface the error. + err := svc.Start(context.Background(), coreapi.Deps{}) + if err == nil { + t.Error("expected error from Start (PortListener stub)") + } +} + +func TestService_StopIdempotentAfterFailedStart(t *testing.T) { + t.Parallel() + rt := newTestRuntime() + svc := NewService(rt) + _ = svc.Start(context.Background(), coreapi.Deps{}) // fails + if err := svc.Stop(context.Background()); err != nil { + t.Errorf("Stop: %v", err) + } + // Second Stop is safe. + if err := svc.Stop(context.Background()); err != nil { + t.Errorf("Stop 2: %v", err) + } +} + +func TestManager_IsTrustedTrustedPeersPendingCount(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + if hm.IsTrusted(0xCAFE) { + t.Error("fresh manager: IsTrusted should be false") + } + if got := hm.TrustedPeers(); len(got) != 0 { + t.Errorf("TrustedPeers fresh = %v", got) + } + if got := hm.PendingCount(); got != 0 { + t.Errorf("PendingCount fresh = %d, want 0", got) + } +} + +func TestManager_WaitForTrust_SelfReturnsTrue(t *testing.T) { + t.Parallel() + // Manager whose runtime has NodeID=0 — and we ask about 0 → self. + hm := newTestHM(t, "") + if !hm.WaitForTrust(0, 10*time.Millisecond) { + t.Error("WaitForTrust(self): want true") + } +} + +func TestManager_WaitForTrust_AlreadyTrusted(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + // Inject a trust record under the lock. + hm.mu.Lock() + hm.trusted[0xCAFE] = &TrustRecord{NodeID: 0xCAFE} + hm.mu.Unlock() + + if !hm.WaitForTrust(0xCAFE, 10*time.Millisecond) { + t.Error("WaitForTrust(trusted): want true") + } +} + +func TestManager_WaitForTrust_Timeout(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + start := time.Now() + if hm.WaitForTrust(0xCAFE, 50*time.Millisecond) { + t.Error("WaitForTrust(unknown): want false on timeout") + } + if elapsed := time.Since(start); elapsed < 40*time.Millisecond { + t.Errorf("returned too fast: %v", elapsed) + } +} + +func TestManager_WaitForTrust_TrustGrantedAfterRegister(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + const peer uint32 = 0x1234 + + go func() { + // Give the WaitForTrust call time to register its channel. + time.Sleep(20 * time.Millisecond) + hm.mu.Lock() + hm.trusted[peer] = &TrustRecord{NodeID: peer} + hm.mu.Unlock() + // Notify waiters. + hm.trustWaitersMu.Lock() + for _, ch := range hm.trustWaiters[peer] { + close(ch) + } + delete(hm.trustWaiters, peer) + hm.trustWaitersMu.Unlock() + }() + + if !hm.WaitForTrust(peer, time.Second) { + t.Error("WaitForTrust should observe the post-registration trust") + } +} + +func TestRemoveWaiter_NoMatchIsNoOp(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + ch := make(chan struct{}) + other := make(chan struct{}) + // Pre-seed a waiter for 7 with the wrong channel. + hm.trustWaitersMu.Lock() + hm.trustWaiters[7] = []chan struct{}{other} + hm.trustWaitersMu.Unlock() + + hm.removeWaiter(7, ch) // ch not in slice → no-op + + hm.trustWaitersMu.Lock() + defer hm.trustWaitersMu.Unlock() + if len(hm.trustWaiters[7]) != 1 || hm.trustWaiters[7][0] != other { + t.Errorf("waiter slice mutated: %v", hm.trustWaiters[7]) + } +} + +func TestRemoveWaiter_RemovesAndCleansEmptySlot(t *testing.T) { + t.Parallel() + hm := newTestHM(t, "") + ch := make(chan struct{}) + hm.trustWaitersMu.Lock() + hm.trustWaiters[7] = []chan struct{}{ch} + hm.trustWaitersMu.Unlock() + + hm.removeWaiter(7, ch) + hm.trustWaitersMu.Lock() + defer hm.trustWaitersMu.Unlock() + if _, ok := hm.trustWaiters[7]; ok { + t.Error("empty waiter slot should be deleted") + } +}