From 1a3450a5b07fe22334f68d9fe75cf63c1d47468f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:29:44 +0100 Subject: [PATCH] test(config): protect failed dormant host ownership transfer Verify failed node-link persistence rolls back both the displaced owner and replacement, preserves report watermarks and reservations, and permits a durable retry on the same store. A rollback-omission negative control reproduces the assertion failure; no runtime behaviour changes. Change-source: pulse-maintainer --- internal/config/host_continuity_test.go | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/config/host_continuity_test.go b/internal/config/host_continuity_test.go index 9de597675..a573d7c26 100644 --- a/internal/config/host_continuity_test.go +++ b/internal/config/host_continuity_test.go @@ -308,3 +308,49 @@ func TestHostContinuityNodeLinkReplacesDormantOwner(t *testing.T) { t.Fatal("replacement reservation incorrect") } } + +// Replacing a dormant owner changes two entries even though the caller supplies +// only the new owner. A failed transaction must roll both back, including the +// reservation used by automatic matching before either host reconnects. +func TestHostContinuityNodeLinkReplacementRollback(t *testing.T) { + dir := t.TempDir() + s := NewHostContinuityStore(dir, nil) + for _, entry := range []HostContinuityEntry{ + {HostID: "owner", LinkedNodeID: "node", NodeLinkSource: "manual", ReportStreamID: "owner-stream", ReportSequence: 42}, + {HostID: "replacement", NodeLinkSource: "unlinked", ReportStreamID: "replacement-stream", ReportSequence: 17}, + } { + if err := s.Upsert(entry); err != nil { + t.Fatal(err) + } + } + assertOwner := func(store *HostContinuityStore, ownerID string) { + t.Helper() + for id, sequence := range map[string]uint64{"owner": 42, "replacement": 17} { + entry, ok := store.Get(id) + wantNode, wantSource := "", "unlinked" + if id == ownerID { + wantNode, wantSource = "node", "manual" + } + if !ok || entry.LinkedNodeID != wantNode || entry.NodeLinkSource != wantSource || entry.ReportSequence != sequence { + t.Fatalf("%s ownership or report watermark changed: %+v", id, entry) + } + if got := store.NodeLinkReservedByOther(id, "node"); got != (id != ownerID) { + t.Fatalf("reservation for %s = %v, owner %s", id, got, ownerID) + } + } + } + s.fs = &mockFSError{FileSystem: defaultFileSystem{}, writeError: errors.New("synthetic journal failure")} + links := []HostContinuityEntry{{HostID: "replacement", LinkedNodeID: "node", NodeLinkSource: "manual"}} + if err := s.SetNodeLinkIntents(links); err == nil { + t.Fatal("replacement acknowledged despite failed persistence") + } + assertOwner(s, "owner") + assertOwner(NewHostContinuityStore(dir, nil), "owner") + // Retry on the same store, not a reload that could conceal memory corruption. + s.fs = defaultFileSystem{} + if err := s.SetNodeLinkIntents(links); err != nil { + t.Fatal(err) + } + assertOwner(s, "replacement") + assertOwner(NewHostContinuityStore(dir, nil), "replacement") +}