From b83d93fdc955363d431885666048661eea920cfb Mon Sep 17 00:00:00 2001 From: naiba Date: Mon, 25 May 2026 10:36:43 +0000 Subject: [PATCH] fix(server-transfer): break loader sort ties by transferID NewServerTransferClass merged Verified and acked-rollback rows and sorted them by AckedAt only. On Windows time.Now() granularity is ~15.6ms, so MarkVerified and an immediately-following MarkRevertDelivered routinely share a timestamp. The stable sort then left the Verified candidate ahead of the rollback that is actually on disk and the agent was locked out on the next restart. Add transferID as a deterministic tiebreaker so the later rotation always wins. --- service/singleton/server_transfer.go | 42 ++++++++++++++++++---------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/service/singleton/server_transfer.go b/service/singleton/server_transfer.go index bdd47f5d..7508f776 100644 --- a/service/singleton/server_transfer.go +++ b/service/singleton/server_transfer.go @@ -306,11 +306,12 @@ func NewServerTransferClass() *ServerTransferClass { } type credCandidate struct { - serverID uint64 - secret string - ackedAt time.Time - isRevert bool - toUserID uint64 + serverID uint64 + transferID uint64 + secret string + ackedAt time.Time + isRevert bool + toUserID uint64 } candidates := make([]credCandidate, 0, len(verified)+len(rollbackAcked)) for i := range verified { @@ -319,10 +320,11 @@ func NewServerTransferClass() *ServerTransferClass { continue } candidates = append(candidates, credCandidate{ - serverID: t.ServerID, - secret: t.HandshakeSecret, - ackedAt: *t.AckedAt, - toUserID: t.ToUserID, + serverID: t.ServerID, + transferID: t.ID, + secret: t.HandshakeSecret, + ackedAt: *t.AckedAt, + toUserID: t.ToUserID, }) } for i := range rollbackAcked { @@ -331,14 +333,26 @@ func NewServerTransferClass() *ServerTransferClass { continue } candidates = append(candidates, credCandidate{ - serverID: t.ServerID, - secret: t.RevertHandshakeSecret, - ackedAt: *t.AckedAt, - isRevert: true, - toUserID: t.FromUserID, + serverID: t.ServerID, + transferID: t.ID, + secret: t.RevertHandshakeSecret, + ackedAt: *t.AckedAt, + isRevert: true, + toUserID: t.FromUserID, }) } + // Sort newest-first by AckedAt, breaking ties with transferID. AckedAt + // alone is not enough on platforms whose time.Now() granularity is + // coarse (Windows: ~15.6ms): MarkVerified and the immediately-following + // MarkRevertDelivered routinely produce identical timestamps, and a + // stable sort then leaves the Verified candidate (appended first) ahead + // of the rollback that is actually on disk, locking the agent out on + // restart. transferID is monotonically increasing within a server's + // transfer lifecycle, so the later rotation always wins the tiebreak. sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].ackedAt.Equal(candidates[j].ackedAt) { + return candidates[i].transferID > candidates[j].transferID + } return candidates[i].ackedAt.After(candidates[j].ackedAt) })