diff --git a/Documentation/ROADMAP.md b/Documentation/ROADMAP.md index 515cea61..8b866c05 100644 --- a/Documentation/ROADMAP.md +++ b/Documentation/ROADMAP.md @@ -7,6 +7,14 @@ > **Convention:** `~~strikethrough~~` = done, removed by design, or YAGNI-deferred. Plain `[ ]` = genuinely open. `[ ] (blocked: …)` = open but waiting on something external. +## Recent Engineering Log + +### 2026-08-17 — Announcer stuck-recovery escape hatch + +- [x] Bound queued DAVE/media recovery: if the announcer remains paused in recovery for 60 seconds, its existing health watchdog now initiates the normal clean voice rejoin instead of leaving it silently connected forever. +- [x] Added voice pipeline state, latest 50 voice events, UDP keepalive history, live transport ownership, voice-resume state, and DAVE/MLS transition diagnostics to exported diagnostics, so voice failures are no longer hidden by noisy main-gateway reconnect logs. +- [x] Ported SwiftMiner's window-attached export progress sheet, so diagnostics visibly prepare before the save sheet appears. + --- ## Overview diff --git a/Sources/SwiftBot/LogExporter.swift b/Sources/SwiftBot/LogExporter.swift index 641d5d31..d8a5712f 100644 --- a/Sources/SwiftBot/LogExporter.swift +++ b/Sources/SwiftBot/LogExporter.swift @@ -75,7 +75,7 @@ enum SwiftBotLogRedactor { enum LogExporter { @MainActor - static func buildReport(from app: AppModel, generatedAt: Date = Date()) -> String { + static func buildReport(from app: AppModel, generatedAt: Date = Date()) async -> String { let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] @@ -130,6 +130,58 @@ enum LogExporter { } out += "\n" + // Voice diagnostics are kept separately from the general system log, + // which can otherwise be dominated by gateway reconnect entries and + // hide the transition that made an announcer go silent. + let voiceHealth = app.announcerHealth + out += "=== Voice Announcer ===\n" + out += "connectionStatus=\(app.voiceConnectionStatus.displayLabel)\n" + out += "phase=\(voiceHealth.phase.rawValue)\n" + out += "queueDepth=\(voiceHealth.queueDepth)\n" + out += "retryStreak=\(voiceHealth.retryStreak)\n" + out += "lastQueuedAt=\(voiceHealth.lastQueuedAt.map { iso.string(from: $0) } ?? "-")\n" + out += "lastSpokenAt=\(voiceHealth.lastSpokenAt.map { iso.string(from: $0) } ?? "-")\n" + out += "lastFailureAt=\(voiceHealth.lastFailureAt.map { iso.string(from: $0) } ?? "-")\n" + out += "lastFailureReason=\(SwiftBotLogRedactor.redact(voiceHealth.lastFailureReason ?? "-"))\n" + out += "\n" + + let transportDiagnostics: VoicePlaybackService.DiagnosticsSnapshot? = if let playback = app.voicePlaybackServiceStorage { + await playback.diagnosticsSnapshot() + } else { + nil + } + out += "=== Voice Transport ===\n" + if let transportDiagnostics { + let stagedTransitions = transportDiagnostics.davePendingTransitionIds + .map(String.init) + .joined(separator: ",") + out += "status=\(transportDiagnostics.status)\n" + out += "connectionGeneration=\(transportDiagnostics.connectionGeneration)\n" + out += "lastFailureGeneration=\(transportDiagnostics.lastFailureGeneration.map(String.init) ?? "-")\n" + out += "lastFailureReason=\(SwiftBotLogRedactor.redact(transportDiagnostics.lastFailureReason ?? "-"))\n" + out += "gateway=\(transportDiagnostics.hasGateway) transport=\(transportDiagnostics.hasTransport) encryption=\(transportDiagnostics.hasEncryption) opus=\(transportDiagnostics.hasOpusEncoder) ssrc=\(transportDiagnostics.hasSSRC)\n" + out += "isSpeaking=\(transportDiagnostics.isSpeaking) speakingElapsedSeconds=\(transportDiagnostics.speakingElapsedSeconds.map { String(format: "%.2f", $0) } ?? "-") firstAudioFrameSent=\(transportDiagnostics.didSendFirstAudioFrame)\n" + out += "lastAudioFrameSentAt=\(transportDiagnostics.lastAudioFrameSentAt.map { iso.string(from: $0) } ?? "-")\n" + out += "keepaliveCounter=\(transportDiagnostics.keepaliveCounter) failures=\(transportDiagnostics.keepaliveFailures)\n" + out += "lastKeepaliveAttemptAt=\(transportDiagnostics.lastKeepaliveAttemptAt.map { iso.string(from: $0) } ?? "-")\n" + out += "lastKeepaliveSuccessAt=\(transportDiagnostics.lastKeepaliveSuccessAt.map { iso.string(from: $0) } ?? "-")\n" + out += "lastKeepaliveFailureAt=\(transportDiagnostics.lastKeepaliveFailureAt.map { iso.string(from: $0) } ?? "-")\n" + out += "lastKeepaliveFailureReason=\(SwiftBotLogRedactor.redact(transportDiagnostics.lastKeepaliveFailureReason ?? "-"))\n" + out += "awaitingVoiceResume=\(transportDiagnostics.awaitingVoiceResume) resumeAttemptsRemaining=\(transportDiagnostics.voiceResumeAttemptsRemaining)\n" + out += "pathRecoveryRequested=\(transportDiagnostics.pathRecoveryRequested) pathRecoveryBudgetRemaining=\(transportDiagnostics.networkPathRecoveryBudgetRemaining)\n" + out += "daveRequired=\(transportDiagnostics.daveMediaRequired) daveGatePending=\(transportDiagnostics.daveTransitionGatePending) daveMediaContextGeneration=\(transportDiagnostics.daveMediaContextGeneration)\n" + out += "daveDowngradeTransitionId=\(transportDiagnostics.pendingDaveDowngradeTransitionId.map(String.init) ?? "-") daveSoleMemberReset=\(transportDiagnostics.pendingDaveSoleMemberReset)\n" + out += "daveSessionGeneration=\(transportDiagnostics.daveSessionGeneration.map(String.init) ?? "-") protocolVersion=\(transportDiagnostics.daveProtocolVersion.map(String.init) ?? "-") handshake=\(transportDiagnostics.daveHandshakeState ?? "-") mediaReady=\(transportDiagnostics.daveMediaReady.map(String.init) ?? "-")\n" + out += "daveAppliedTransitions=\(transportDiagnostics.daveAppliedTransitionCount.map(String.init) ?? "-") pendingEpoch=\(transportDiagnostics.davePendingEpoch.map(String.init) ?? "-") pendingTransition=\(transportDiagnostics.davePendingTransitionId.map(String.init) ?? "-") activeTransition=\(transportDiagnostics.daveActiveTransitionId.map(String.init) ?? "-")\n" + out += "daveStagedTransitions=\(stagedTransitions.isEmpty ? "-" : stagedTransitions) pendingOutboundActions=\(transportDiagnostics.davePendingOutboundActionCount.map(String.init) ?? "-") lastRecoveryAction=\(transportDiagnostics.daveLastRecoveryAction ?? "-")\n" + out += "daveLastTransitionAt=\(transportDiagnostics.daveLastTransitionAt.map { iso.string(from: $0) } ?? "-")\n" + out += "daveEncryptSuccesses=\(transportDiagnostics.daveEncryptionSuccessCount.map(String.init) ?? "-") failures=\(transportDiagnostics.daveEncryptionFailureCount.map(String.init) ?? "-")\n" + out += "daveLastMlsError=\(SwiftBotLogRedactor.redact(transportDiagnostics.daveLastMlsError ?? "-"))\n" + } else { + out += "state=not initialized\n" + } + out += "\n" + // Cluster / SwiftMesh out += "=== SwiftMesh ===\n" let cs = app.clusterSnapshot @@ -211,6 +263,16 @@ enum LogExporter { out += "\(ts) \(status) \(SwiftBotLogRedactor.redact(c.user)) · \(SwiftBotLogRedactor.redact(c.server)) · \(SwiftBotLogRedactor.redact(c.channel)) · route=\(c.executionRoute) on=\(c.executionNode) · \(SwiftBotLogRedactor.redact(c.command))\n" } } + + out += "\n-- Voice Log (most recent 50) --\n" + let voiceEntries = app.voiceLog.prefix(50) + if voiceEntries.isEmpty { + out += "(none)\n" + } else { + for entry in voiceEntries { + out += "[\(iso.string(from: entry.time))] \(SwiftBotLogRedactor.redact(entry.description))\n" + } + } out += "\n-- System Log (most recent 500 lines) --\n" let lines = app.logs.lines.suffix(500) if lines.isEmpty { @@ -224,20 +286,28 @@ enum LogExporter { return out } + /// Presents SwiftMiner's export flow: first a small, window-attached progress + /// sheet while potentially slow diagnostics are gathered, then a save sheet. @MainActor - static func presentSavePanel(app: AppModel) { + static func presentSavePanel(app: AppModel) async { + let progressSheet = presentProgressSheet() + defer { dismiss(progressSheet) } + + // Give AppKit a chance to display feedback before awaiting actor-backed + // voice/DAVE diagnostics and redacting a potentially large activity log. + await Task.yield() let generatedAt = Date() - let report = buildReport(from: app, generatedAt: generatedAt) + let report = await buildReport(from: app, generatedAt: generatedAt) let panel = NSSavePanel() panel.allowedContentTypes = [.plainText] panel.nameFieldStringValue = defaultFilename(for: generatedAt) panel.title = "Export Diagnostic Logs" - panel.message = "Save a redacted SwiftBot diagnostic report. Discord tokens, mesh secrets, API keys, snowflakes, and emails are scrubbed." + panel.message = "Save a redacted SwiftBot diagnostic report you can attach to a GitHub issue." panel.canCreateDirectories = true panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first - guard panel.runModal() == .OK, let url = panel.url else { return } + guard await present(panel) == .OK, let url = panel.url else { return } do { try report.write(to: url, atomically: true, encoding: .utf8) @@ -251,6 +321,86 @@ enum LogExporter { } } + /// A save panel started from a SwiftUI command needs a window-attached sheet + /// on current macOS releases. `runModal()` can return without presenting in + /// that context, which made exporting look like a no-op. + @MainActor + private static func present(_ panel: NSSavePanel) async -> NSApplication.ModalResponse { + guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { + return panel.runModal() + } + + return await withCheckedContinuation { continuation in + panel.beginSheetModal(for: window) { response in + continuation.resume(returning: response) + } + } + } + + private struct ProgressSheet { + let panel: NSPanel + let parentWindow: NSWindow? + let symbolCycler: DiagnosticsSymbolCycler + } + + /// Displays immediately while the report is snapshotted, redacted, and + /// formatted, avoiding an export action that appears to have been ignored. + @MainActor + private static func presentProgressSheet() -> ProgressSheet { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 154), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + panel.title = "Export Diagnostic Logs" + panel.isReleasedWhenClosed = false + panel.isMovable = false + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + let content = NSView(frame: panel.contentView?.bounds ?? .zero) + + let symbolCycler = DiagnosticsSymbolCycler(frame: NSRect(x: 27, y: 71, width: 30, height: 30)) + content.addSubview(symbolCycler) + + let title = NSTextField(labelWithString: "Preparing diagnostics…") + title.font = .systemFont(ofSize: 16, weight: .semibold) + title.frame = NSRect(x: 74, y: 90, width: 272, height: 22) + content.addSubview(title) + + let detail = NSTextField(wrappingLabelWithString: "Collecting announcer, Discord voice, and activity data. This can take a moment for a large log.") + detail.font = .systemFont(ofSize: 13) + detail.textColor = .secondaryLabelColor + detail.maximumNumberOfLines = 2 + detail.frame = NSRect(x: 74, y: 42, width: 272, height: 40) + content.addSubview(detail) + + panel.contentView = content + symbolCycler.start() + + if let parentWindow = NSApp.keyWindow ?? NSApp.mainWindow { + parentWindow.beginSheet(panel) + return ProgressSheet(panel: panel, parentWindow: parentWindow, symbolCycler: symbolCycler) + } + + panel.center() + panel.level = .floating + panel.makeKeyAndOrderFront(nil) + return ProgressSheet(panel: panel, parentWindow: nil, symbolCycler: symbolCycler) + } + + @MainActor + private static func dismiss(_ progressSheet: ProgressSheet) { + progressSheet.symbolCycler.stop() + if let parentWindow = progressSheet.parentWindow { + parentWindow.endSheet(progressSheet.panel) + } else { + progressSheet.panel.orderOut(nil) + } + } + static func defaultFilename(for date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -259,3 +409,58 @@ enum LogExporter { return "SwiftBot-logs-\(formatter.string(from: date)).txt" } } + +/// Cycles through relevant symbols while diagnostics are prepared, providing +/// the same visible activity cue as SwiftMiner's diagnostics export sheet. +private final class DiagnosticsSymbolCycler: NSImageView { + private let symbols: [(name: String, color: NSColor)] = [ + ("speaker.wave.2.fill", .systemBlue), + ("waveform", .systemPurple), + ("antenna.radiowaves.left.and.right", .systemIndigo), + ("text.bubble.fill", .systemTeal) + ] + private var symbolIndex = 0 + private var timer: Timer? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + imageScaling = .scaleProportionallyUpOrDown + setAccessibilityLabel("Preparing diagnostics") + updateSymbol() + } + + required init?(coder: NSCoder) { + nil + } + + func start() { + guard timer == nil else { return } + + let timer = Timer( + timeInterval: 0.65, + target: self, + selector: #selector(advanceSymbol), + userInfo: nil, + repeats: true + ) + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stop() { + timer?.invalidate() + timer = nil + } + + @objc private func advanceSymbol() { + symbolIndex = (symbolIndex + 1) % symbols.count + updateSymbol() + } + + private func updateSymbol() { + let symbol = symbols[symbolIndex] + contentTintColor = symbol.color + image = NSImage(systemSymbolName: symbol.name, accessibilityDescription: "Preparing diagnostics") + } + +} diff --git a/Sources/SwiftBot/Models/VoiceAnnouncerHealth.swift b/Sources/SwiftBot/Models/VoiceAnnouncerHealth.swift index eedba10f..25575a19 100644 --- a/Sources/SwiftBot/Models/VoiceAnnouncerHealth.swift +++ b/Sources/SwiftBot/Models/VoiceAnnouncerHealth.swift @@ -40,17 +40,28 @@ struct VoiceAnnouncerHealth: Sendable, Equatable { var isDraining: Bool = false func isStalled(now: Date = Date(), threshold: TimeInterval = 60) -> Bool { - guard !isPaused else { return false } switch phase { case .rendering, .sending: + guard !isPaused else { return false } guard let activeStartedAt else { return false } return now.timeIntervalSince(activeStartedAt) >= threshold case .queued: + guard !isPaused else { return false } guard queueDepth > 0, let lastQueuedAt else { return false } return now.timeIntervalSince(lastQueuedAt) >= threshold case .failed: return true - case .idle, .paused, .recovering: + case .recovering: + // A short DAVE media re-key is expected, but a recovery with + // queued reads must be bounded. Previously this state was + // considered healthy forever. Recovery deliberately pauses the + // queue, so `isPaused` must not suppress this check; otherwise a + // lost media-ready callback leaves the bot connected but silent. + guard queueDepth > 0 else { return false } + let recoveryStartedAt = lastFailureAt ?? lastRecoveryAt ?? lastQueuedAt + guard let recoveryStartedAt else { return false } + return now.timeIntervalSince(recoveryStartedAt) >= threshold + case .idle, .paused: return false } } diff --git a/Sources/SwiftBot/Services/VoicePlaybackService.swift b/Sources/SwiftBot/Services/VoicePlaybackService.swift index cb6d1013..d49b5c2e 100644 --- a/Sources/SwiftBot/Services/VoicePlaybackService.swift +++ b/Sources/SwiftBot/Services/VoicePlaybackService.swift @@ -11,6 +11,55 @@ import libdave_swift actor VoicePlaybackService { private static let logger = Logger(subsystem: "com.swiftbot", category: "voice.playback") + /// Redacted, export-safe state for investigating an announcer that appears + /// connected but is not delivering audio. It intentionally contains no + /// endpoint, token, key material, or audio content. + struct DiagnosticsSnapshot: Sendable { + let status: String + let connectionGeneration: UInt64 + let lastFailureGeneration: UInt64? + let lastFailureReason: String? + let hasGateway: Bool + let hasTransport: Bool + let hasEncryption: Bool + let hasOpusEncoder: Bool + let hasSSRC: Bool + let isSpeaking: Bool + let speakingElapsedSeconds: Double? + let didSendFirstAudioFrame: Bool + let lastAudioFrameSentAt: Date? + let keepaliveCounter: UInt32 + let keepaliveFailures: Int + let lastKeepaliveAttemptAt: Date? + let lastKeepaliveSuccessAt: Date? + let lastKeepaliveFailureAt: Date? + let lastKeepaliveFailureReason: String? + let awaitingVoiceResume: Bool + let voiceResumeAttemptsRemaining: Int + let pathRecoveryRequested: Bool + let networkPathRecoveryBudgetRemaining: Int + let daveMediaRequired: Bool + let daveTransitionGatePending: Bool + let daveMediaContextGeneration: UInt64 + let pendingDaveDowngradeTransitionId: UInt64? + let pendingDaveSoleMemberReset: Bool + let daveSessionGeneration: UInt64? + let daveProtocolVersion: UInt16? + let daveHandshakeState: String? + let daveMediaReady: Bool? + let daveAppliedTransitionCount: UInt64? + let davePendingEpoch: UInt64? + let davePendingTransitionId: UInt64? + let daveActiveTransitionId: UInt64? + let davePendingTransitionIds: [UInt64] + let davePendingOutboundActionCount: Int? + let daveLastRecoveryAction: String? + let daveLastMlsError: String? + let daveLastTransitionAt: Date? + let daveEncryptionSuccessCount: UInt64? + let daveEncryptionFailureCount: UInt64? + } + enum Status: Sendable, Equatable { case idle case connecting @@ -183,6 +232,11 @@ actor VoicePlaybackService { private var keepaliveTask: Task? private var keepaliveCounter: UInt32 = 0 private var keepaliveFailureCount: Int = 0 + private var lastKeepaliveAttemptAt: Date? + private var lastKeepaliveSuccessAt: Date? + private var lastKeepaliveFailureAt: Date? + private var lastKeepaliveFailureReason: String? + private var lastAudioFrameSentAt: Date? /// A path monitor can report several details for one route handoff. Once a /// usable new route has requested recovery, ignore the rest until the next /// connection generation owns a fresh UDP transport. @@ -459,6 +513,58 @@ actor VoicePlaybackService { return nil } + func diagnosticsSnapshot() async -> DiagnosticsSnapshot { + let dave = await daveCoordinator?.getDiagnostics() + let speakingElapsedSeconds = speakingStartedAt.map { start in + Self.durationSeconds(ContinuousClock().now - start) + } + return DiagnosticsSnapshot( + status: label(for: status), + connectionGeneration: connectionGeneration, + lastFailureGeneration: lastFailureGeneration, + lastFailureReason: lastFailureReason, + hasGateway: gateway != nil, + hasTransport: transport != nil, + hasEncryption: encryption != nil, + hasOpusEncoder: opus != nil, + hasSSRC: ssrc != nil, + isSpeaking: isSpeaking, + speakingElapsedSeconds: speakingElapsedSeconds, + didSendFirstAudioFrame: didSendFirstAudioFrameForSpeech, + lastAudioFrameSentAt: lastAudioFrameSentAt, + keepaliveCounter: keepaliveCounter, + keepaliveFailures: keepaliveFailureCount, + lastKeepaliveAttemptAt: lastKeepaliveAttemptAt, + lastKeepaliveSuccessAt: lastKeepaliveSuccessAt, + lastKeepaliveFailureAt: lastKeepaliveFailureAt, + lastKeepaliveFailureReason: lastKeepaliveFailureReason, + awaitingVoiceResume: awaitingVoiceResume, + voiceResumeAttemptsRemaining: voiceResumeAttemptsRemaining, + pathRecoveryRequested: pathRecoveryRequested, + networkPathRecoveryBudgetRemaining: networkPathRecoveryBudgetRemaining, + daveMediaRequired: daveMediaRequired, + daveTransitionGatePending: daveTransitionGatePending, + daveMediaContextGeneration: daveMediaContextGeneration, + pendingDaveDowngradeTransitionId: pendingDaveDowngradeTransitionId, + pendingDaveSoleMemberReset: pendingDaveSoleMemberReset, + daveSessionGeneration: dave?.sessionGeneration, + daveProtocolVersion: dave?.protocolVersion, + daveHandshakeState: dave?.handshakeState.rawValue, + daveMediaReady: dave?.mediaReady, + daveAppliedTransitionCount: dave?.appliedTransitionCount, + davePendingEpoch: dave?.pendingEpoch, + davePendingTransitionId: dave?.pendingTransitionId, + daveActiveTransitionId: dave?.activeTransitionId, + davePendingTransitionIds: dave?.pendingTransitionIds ?? [], + davePendingOutboundActionCount: dave?.pendingOutboundActionCount, + daveLastRecoveryAction: dave?.lastRecoveryAction?.rawValue, + daveLastMlsError: dave?.lastMlsError, + daveLastTransitionAt: dave?.lastTransitionTimestamp, + daveEncryptionSuccessCount: dave?.encryptionStats?.encryptSuccessCount, + daveEncryptionFailureCount: dave?.encryptionStats?.encryptFailureCount + ) + } + /// The libdave coordinator is the single source of truth for DAVE media /// readiness. Keeping a second Boolean here caused SwiftBot to resume /// speech before the matching Execute Transition had installed the new @@ -875,6 +981,7 @@ actor VoicePlaybackService { guard isCurrentConnection(generation) else { throw VoicePipelineError.notConnected } + lastAudioFrameSentAt = Date() self.rtp = rtp self.encryption = encryption await noteFirstAudioFrameSent(generation: generation) @@ -1968,8 +2075,12 @@ actor VoicePlaybackService { packet[2] = UInt8((counter >> 16) & 0xff) packet[3] = UInt8((counter >> 24) & 0xff) keepaliveCounter &+= 1 + lastKeepaliveAttemptAt = Date() try await transport.send(packet) - return isCurrentConnection(generation) + guard isCurrentConnection(generation) else { return false } + lastKeepaliveSuccessAt = Date() + lastKeepaliveFailureReason = nil + return true } private func resetKeepaliveFailureCount(generation: UInt64) { @@ -1980,6 +2091,8 @@ actor VoicePlaybackService { private func handleKeepaliveFailure(_ error: Error, generation: UInt64) async { guard isCurrentConnection(generation), status == .connected else { return } keepaliveFailureCount += 1 + lastKeepaliveFailureAt = Date() + lastKeepaliveFailureReason = error.localizedDescription await debug("Voice UDP keepalive failed (\(keepaliveFailureCount)/2): \(error.localizedDescription)") guard isCurrentConnection(generation) else { return } if keepaliveFailureCount >= 2 { @@ -2087,6 +2200,11 @@ actor VoicePlaybackService { private func beginConnection() -> UInt64 { connectionGeneration &+= 1 cancelConnectionWorkers() + lastKeepaliveAttemptAt = nil + lastKeepaliveSuccessAt = nil + lastKeepaliveFailureAt = nil + lastKeepaliveFailureReason = nil + lastAudioFrameSentAt = nil return connectionGeneration } @@ -2332,11 +2450,15 @@ actor VoicePlaybackService { } private static func format(_ duration: Duration) -> String { - let seconds = Double(duration.components.seconds) - + Double(duration.components.attoseconds) / 1_000_000_000_000_000_000 + let seconds = durationSeconds(duration) return String(format: "%.1fs", seconds) } + private static func durationSeconds(_ duration: Duration) -> Double { + Double(duration.components.seconds) + + Double(duration.components.attoseconds) / 1_000_000_000_000_000_000 + } + /// Time since the current connect attempt began, e.g. "2.1s", for timing the /// handshake phases in the diagnostics log. "?" before a connect starts. private func elapsedSinceConnect() -> String { diff --git a/Sources/SwiftBot/SwiftBotApp.swift b/Sources/SwiftBot/SwiftBotApp.swift index e7322934..7077d09b 100644 --- a/Sources/SwiftBot/SwiftBotApp.swift +++ b/Sources/SwiftBot/SwiftBotApp.swift @@ -195,7 +195,9 @@ struct SwiftBotApp: App { // redacted diagnostic report for issue reports / debugging. CommandGroup(after: .help) { Button("Export Diagnostic Logs…") { - LogExporter.presentSavePanel(app: appModel) + Task { + await LogExporter.presentSavePanel(app: appModel) + } } .keyboardShortcut("e", modifiers: [.command, .shift]) } diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index 5c93af98..3e7b4b9d 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -1361,7 +1361,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2026080715; + CURRENT_PROJECT_VERSION = 2026081718; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1388,7 +1388,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.22.5; + MARKETING_VERSION = 1.22.6; PRODUCT_BUNDLE_IDENTIFIER = com.example.swiftbot; PRODUCT_NAME = SwiftBot; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1599,7 +1599,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2026080715; + CURRENT_PROJECT_VERSION = 2026081718; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1626,7 +1626,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.22.5; + MARKETING_VERSION = 1.22.6; PRODUCT_BUNDLE_IDENTIFIER = com.example.swiftbot; PRODUCT_NAME = SwiftBot; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Tests/SwiftBotTests/VoiceRecoveryBackoffTests.swift b/Tests/SwiftBotTests/VoiceRecoveryBackoffTests.swift index a30224b4..cc4011c5 100644 --- a/Tests/SwiftBotTests/VoiceRecoveryBackoffTests.swift +++ b/Tests/SwiftBotTests/VoiceRecoveryBackoffTests.swift @@ -2,6 +2,25 @@ import XCTest @testable import SwiftBot final class VoiceRecoveryBackoffTests: XCTestCase { + func testQueuedRecoveryBecomesStalledAfterThreshold() { + var health = VoiceAnnouncerHealth() + health.phase = .recovering + health.queueDepth = 1 + health.isPaused = true + health.lastFailureAt = Date(timeIntervalSinceReferenceDate: 100) + + XCTAssertFalse(health.isStalled(now: Date(timeIntervalSinceReferenceDate: 159), threshold: 60)) + XCTAssertTrue(health.isStalled(now: Date(timeIntervalSinceReferenceDate: 160), threshold: 60)) + } + + func testEmptyRecoveryDoesNotTriggerAReconnect() { + var health = VoiceAnnouncerHealth() + health.phase = .recovering + health.lastRecoveryAt = Date(timeIntervalSinceReferenceDate: 100) + + XCTAssertFalse(health.isStalled(now: Date(timeIntervalSinceReferenceDate: 1_000), threshold: 60)) + } + func testAttemptsFollowScheduleThenExhaust() { var backoff = VoiceRecoveryBackoff(schedule: [.seconds(1), .seconds(2), .seconds(3)]) diff --git a/docs/release-notes/1.22.6.html b/docs/release-notes/1.22.6.html new file mode 100644 index 00000000..af2aa6a6 --- /dev/null +++ b/docs/release-notes/1.22.6.html @@ -0,0 +1,70 @@ + + + + + + SwiftBot 1.22.6 - Release Notes + + + +
+

SwiftBot 1.22.6

+

Version 1.22.6 · Build 2026081718

+

A focused Announcer reliability update: a voice recovery that gets stuck waiting for secure media now recovers itself, and the next diagnostic report will say exactly where the voice pipeline stopped.

+ +
+

Stuck Voice Recovery Rejoins

+

The bot could remain visibly connected but silently pause its announcement queue after a Discord DAVE media transition failed to finish.

+
    +
  • Bounded recovery: If a queued Announcer remains paused while recovering for 60 seconds, SwiftBot now uses its normal clean voice rejoin instead of waiting indefinitely.
  • +
  • Safe watchdog behaviour: A normally paused or empty Announcer is not treated as failed; the recovery escape hatch applies only when work is still waiting to be spoken.
  • +
+
+ +
+

Voice Diagnostics You Can Use

+

Export Diagnostic Logs is now purpose-built for chasing the "connected but silent" failure mode.

+
    +
  • More evidence: Exports include Announcer health, recent voice events, audio-frame timing, UDP keepalive history, voice resume state, and DAVE/MLS transition state.
  • +
  • Visible export progress: A SwiftMiner-style, window-attached preparation sheet appears while the report is collected, redacted, and formatted before the save sheet opens.
  • +
  • Safe to share: The report continues to scrub Discord tokens, mesh secrets, API keys, IDs, and email addresses.
  • +
+
+ +
+

Release Details

+

Regression coverage accompanies the reliability fix.

+
    +
  • Updated MARKETING_VERSION to 1.22.6 and CURRENT_PROJECT_VERSION to 2026081718.
  • +
  • Added coverage for queued paused recoveries and confirmed that empty recoveries remain non-failing.
  • +
+
+
+ + diff --git a/project.yml b/project.yml index 0463f951..c302f4bc 100644 --- a/project.yml +++ b/project.yml @@ -126,8 +126,8 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: SwiftBot ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: NO COMBINE_HIDPI_IMAGES: YES - CURRENT_PROJECT_VERSION: "2026080715" - MARKETING_VERSION: "1.22.5" + CURRENT_PROJECT_VERSION: "2026081718" + MARKETING_VERSION: "1.22.6" DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Manual PROVISIONING_PROFILE_SPECIFIER: ""