Deflake gift-wrap tests: settle deadlines, not latency budgets (#1651)

* Deflake gift-wrap tests: settle deadlines, not latency budgets

The gift-wrap round-trip tests failed three CI runs this week
(handleGiftWrap_privateMessageStoresConversationAndMapping,
handleGiftWrap_deliveredAckUpdatesExistingMessage,
handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with
the same signature: the async NIP-17 unwrap missed a 5s wait on a
loaded runner. 40 local iterations of both suites pass clean — the
failures are scheduler starvation, exactly the class
TestConstants.settleTimeout documents.

Every positive wait in ChatViewModelExtensionsTests and
ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the
explicit 5.0s literals on the gift-wrap waits, the longTimeout media
waits, and the bare-default channel-switch waits. All are
expected-true waits, so passing runs return immediately and never pay
the deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake SimulatedMesh TTL budget: settle discovery before the baseline

publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its
frame baseline after a single 2s advance, but discovery is not quiet
by then: every first-seen peer schedules an afterglow re-announce at a
random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can
cascade another relay round. Whether that traffic lands before or
after the snapshot depends on the draw — CI measured the "single
message" at 14 and 18 frames against a budget of 12.

The test now advances until the mesh goes a full window with no new
frames before taking the baseline, so the budget only ever measures
the message under test. 30 local iterations green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-10 07:56:12 +02:00 committed by GitHub
parent f0249d9cd7
commit 6ef3945179
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 34 additions and 20 deletions

View File

@ -356,7 +356,7 @@ struct ChatNostrCoordinatorContextTests {
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
@ -412,7 +412,7 @@ struct ChatNostrCoordinatorContextTests {
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(delivered)
}

View File

@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests {
LocationChannelManager.shared.select(channel)
defer { LocationChannelManager.shared.select(.mesh) }
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout)
let (viewModel, _) = makeTestableViewModel()
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout)
let signer = try NostrIdentity.generate()
let event = NostrEvent(
@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleNostrEvent(signed)
}
return false
}, timeout: TestConstants.longTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(didAppend)
}
@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests {
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didUpdate)
}
@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests {
let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didUpdate)
}
@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests {
let didStore = await TestHelpers.waitUntil(
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didStore)
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
@ -563,7 +563,7 @@ struct ChatViewModelNostrExtensionTests {
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didAck)
#expect(viewModel.privateChats[convKey] == nil)
@ -602,7 +602,7 @@ struct ChatViewModelNostrExtensionTests {
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didUpdate)
}
@ -1022,10 +1022,10 @@ struct ChatViewModelMediaTransferTests {
viewModel.sendVoiceNote(at: url)
// Media sends hop through Task.detached; the global executor is
// shared with every parallel test worker, so a loaded runner can
// exceed the 5s default. waitUntil returns as soon as the condition
// holds, so passing runs never pay the longer timeout.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
// shared with every parallel test worker, so a loaded runner can be
// starved for seconds. waitUntil returns as soon as the condition
// holds, so passing runs never pay the settle deadline.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@ -1056,7 +1056,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
let showedSecond = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
#expect(showedSecond)
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
@ -1098,7 +1098,7 @@ struct ChatViewModelMediaTransferTests {
)
let advanced = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
#expect(advanced)
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
@ -1128,7 +1128,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: TestConstants.longTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
@ -1144,7 +1144,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@ -1165,7 +1165,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: TestConstants.longTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)

View File

@ -56,7 +56,21 @@ struct SimulatedMeshTests {
mesh.connect(1, 2)
mesh.announceAll()
mesh.advanceTime(by: 2)
// Discovery is not quiet after one advance: every first-seen peer
// schedules an afterglow re-announce at a RANDOM 0.30.6s delay
// (BLEAnnounceHandler), and each of those can cascade another relay
// round. Whether that traffic lands before or after a one-shot
// baseline snapshot depends on the draw the budget assertion below
// flaked on CI at 14 and 18 frames for exactly that reason. Advance
// until the mesh goes a full window with no new frames, so the
// baseline only ever measures the message under test.
var settled = mesh.deliveredFrameCount
for _ in 0..<20 {
mesh.advanceTime(by: 2)
let now = mesh.deliveredFrameCount
if now == settled { break }
settled = now
}
let baseline = mesh.deliveredFrameCount
let capture = TransportEventCapture()