diff --git a/Packages/StratixCore/Sources/StratixCore/Hydration/LibraryHydrationWorker.swift b/Packages/StratixCore/Sources/StratixCore/Hydration/LibraryHydrationWorker.swift index 9f120e3..0872bc6 100644 --- a/Packages/StratixCore/Sources/StratixCore/Hydration/LibraryHydrationWorker.swift +++ b/Packages/StratixCore/Sources/StratixCore/Hydration/LibraryHydrationWorker.swift @@ -34,11 +34,14 @@ actor LibraryHydrationWorker: LibraryHydrationWorking { init( detailsCacheURL: URL, sectionsCacheURL: URL, + repositoryStoreURL: URL? = nil, homeMerchandisingCacheURL _: URL ) { let repository: any LibraryRepository do { - repository = try SwiftDataLibraryRepository(storeURL: sectionsCacheURL) + let storeURL = repositoryStoreURL + ?? sectionsCacheURL.deletingPathExtension().appendingPathExtension("swiftdata") + repository = try SwiftDataLibraryRepository(storeURL: storeURL) } catch { fatalError("Failed to initialize library repository: \(error)") } diff --git a/Packages/StratixCore/Sources/StratixCore/Hydration/SwiftDataLibraryRepository.swift b/Packages/StratixCore/Sources/StratixCore/Hydration/SwiftDataLibraryRepository.swift index f8849be..1275c89 100644 --- a/Packages/StratixCore/Sources/StratixCore/Hydration/SwiftDataLibraryRepository.swift +++ b/Packages/StratixCore/Sources/StratixCore/Hydration/SwiftDataLibraryRepository.swift @@ -39,22 +39,10 @@ actor SwiftDataLibraryRepository: LibraryRepository { isStoredInMemoryOnly: Bool = false ) throws { let schema = Schema([UnifiedLibraryCacheRecord.self]) - let configuration = if let storeURL { - ModelConfiguration( - Self.storeName, - schema: schema, - url: storeURL - ) - } else { - ModelConfiguration( - Self.storeName, - schema: schema, - isStoredInMemoryOnly: isStoredInMemoryOnly - ) - } - self.modelContainer = try ModelContainer( - for: schema, - configurations: [configuration] + self.modelContainer = try Self.makeModelContainer( + schema: schema, + storeURL: storeURL, + isStoredInMemoryOnly: isStoredInMemoryOnly ) } @@ -152,6 +140,70 @@ actor SwiftDataLibraryRepository: LibraryRepository { ModelContext(modelContainer) } + private static func makeModelContainer( + schema: Schema, + storeURL: URL?, + isStoredInMemoryOnly: Bool + ) throws -> ModelContainer { + if let storeURL, !isStoredInMemoryOnly { + try ensureParentDirectoryExists(for: storeURL) + } + + let configuration = if let storeURL { + ModelConfiguration( + Self.storeName, + schema: schema, + url: storeURL + ) + } else { + ModelConfiguration( + Self.storeName, + schema: schema, + isStoredInMemoryOnly: isStoredInMemoryOnly + ) + } + + do { + return try ModelContainer( + for: schema, + configurations: [configuration] + ) + } catch { + guard let storeURL, !isStoredInMemoryOnly else { + throw error + } + + // The unified library snapshot is a disposable cache. If the on-disk store is + // unreadable or incompatible, clear it once and rebuild the container. + resetStoreArtifacts(at: storeURL) + try ensureParentDirectoryExists(for: storeURL) + return try ModelContainer( + for: schema, + configurations: [configuration] + ) + } + } + + private static func ensureParentDirectoryExists(for storeURL: URL) throws { + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } + + private static func resetStoreArtifacts(at storeURL: URL) { + let fileManager = FileManager.default + let artifactURLs = [ + storeURL, + URL(fileURLWithPath: storeURL.path + "-shm"), + URL(fileURLWithPath: storeURL.path + "-wal"), + ] + + for artifactURL in artifactURLs { + try? fileManager.removeItem(at: artifactURL) + } + } + /// Preserves the untouched portion of the unified snapshot when only one cache fragment changed. private func mergedSnapshot( sections: [CloudLibrarySection]?, diff --git a/Packages/StratixCore/Sources/StratixCore/InputController.swift b/Packages/StratixCore/Sources/StratixCore/InputController.swift index 5bd6be2..98f5ae5 100644 --- a/Packages/StratixCore/Sources/StratixCore/InputController.swift +++ b/Packages/StratixCore/Sources/StratixCore/InputController.swift @@ -120,6 +120,7 @@ public final class InputController { @ObservationIgnored private var startupHapticsProbeControllers: Set = [] @ObservationIgnored private var didRunAppLaunchHapticsProbe = false @ObservationIgnored private var hapticsProbeTask: Task? + @ObservationIgnored private var activeStreamingSession: (any StreamingSessionFacade)? @ObservationIgnored private var activeInputQueue: InputQueue? @ObservationIgnored private var didConfigureControllerObservers = false init() {} @@ -152,8 +153,9 @@ public final class InputController { settingsStore.diagnostics.startupHapticsProbeEnabled } - func setupControllerObservation(inputQueue: InputQueue) { - activeInputQueue = inputQueue + func setupControllerObservation(streamingSession: any StreamingSessionFacade) { + activeStreamingSession = streamingSession + activeInputQueue = streamingSession.inputQueueRef for controller in GCController.controllers() { attachController(controller) @@ -212,7 +214,7 @@ public final class InputController { } func injectNeutralGamepadFrame(index: UInt8 = 0) { - guard let session = dependencies?.currentStreamingSession() else { return } + guard let session = activeStreamingSession ?? dependencies?.currentStreamingSession() else { return } let frame = GamepadInputFrame( gamepadIndex: index, buttons: [], @@ -224,7 +226,7 @@ public final class InputController { } func injectPauseMenuTap(index: UInt8 = 0) { - guard let session = dependencies?.currentStreamingSession() else { return } + guard let session = activeStreamingSession ?? dependencies?.currentStreamingSession() else { return } guard case .connected = session.lifecycle else { return } let pressed = GamepadInputFrame( @@ -276,6 +278,7 @@ public final class InputController { } func clearStreamingInputBindings() { + activeStreamingSession = nil activeInputQueue = nil } @@ -383,7 +386,7 @@ public final class InputController { queue.enqueueGamepadFrame(frame) } - dependencies?.currentStreamingSession()?.setGamepadConnectionState(index: 0, connected: true) + activeStreamingSession?.setGamepadConnectionState(index: 0, connected: true) logger.info("Controller attached: \(controller.vendorName ?? "Unknown")") } @@ -468,7 +471,7 @@ public final class InputController { comboInterpreters.removeValue(forKey: controllerID)?.cancelAll() startupHapticsProbeControllers.remove(controllerID) let isAnyExtendedControllerConnected = GCController.controllers().contains { $0.extendedGamepad != nil } - dependencies?.currentStreamingSession()?.setGamepadConnectionState( + activeStreamingSession?.setGamepadConnectionState( index: 0, connected: isAnyExtendedControllerConnected ) diff --git a/Packages/StratixCore/Sources/StratixCore/LibraryController.swift b/Packages/StratixCore/Sources/StratixCore/LibraryController.swift index ad76468..a5ebbe8 100644 --- a/Packages/StratixCore/Sources/StratixCore/LibraryController.swift +++ b/Packages/StratixCore/Sources/StratixCore/LibraryController.swift @@ -20,16 +20,26 @@ public final class LibraryController { struct CacheLocations: Sendable { let details: URL let sections: URL + let repository: URL let homeMerchandising: URL - var repository: URL { - sections.deletingPathExtension().appendingPathExtension("swiftdata") + init( + details: URL, + sections: URL, + repository: URL? = nil, + homeMerchandising: URL + ) { + self.details = details + self.sections = sections + self.repository = repository ?? sections.deletingPathExtension().appendingPathExtension("swiftdata") + self.homeMerchandising = homeMerchandising } static var live: Self { Self( details: LibraryController.detailsCacheURL, sections: LibraryController.sectionsCacheURL, + repository: LibraryController.libraryRepositoryStoreURL, homeMerchandising: LibraryController.homeMerchandisingCacheURL ) } @@ -373,6 +383,10 @@ extension LibraryController { MetadataCacheStore.url(for: "stratix.cloudLibrarySections.json") } + nonisolated static var libraryRepositoryStoreURL: URL { + MetadataCacheStore.cacheURL(for: "stratix.cloudLibrarySections.swiftdata") + } + nonisolated static var homeMerchandisingCacheURL: URL { MetadataCacheStore.url(for: "stratix.homeMerchandising.v1.json") } diff --git a/Packages/StratixCore/Sources/StratixCore/LibraryHydrationPersistence.swift b/Packages/StratixCore/Sources/StratixCore/LibraryHydrationPersistence.swift index a99b77d..8cec779 100644 --- a/Packages/StratixCore/Sources/StratixCore/LibraryHydrationPersistence.swift +++ b/Packages/StratixCore/Sources/StratixCore/LibraryHydrationPersistence.swift @@ -85,7 +85,10 @@ actor LibraryHydrationPersistenceStore { ) { let repository: any LibraryRepository do { - repository = try SwiftDataLibraryRepository(storeURL: sectionsURL) + let storeURL = sectionsURL + .deletingPathExtension() + .appendingPathExtension("swiftdata") + repository = try SwiftDataLibraryRepository(storeURL: storeURL) } catch { fatalError("Failed to initialize library repository: \(error)") } diff --git a/Packages/StratixCore/Sources/StratixCore/LibraryPersistenceCoordinator.swift b/Packages/StratixCore/Sources/StratixCore/LibraryPersistenceCoordinator.swift index 2d33aa7..1c73290 100644 --- a/Packages/StratixCore/Sources/StratixCore/LibraryPersistenceCoordinator.swift +++ b/Packages/StratixCore/Sources/StratixCore/LibraryPersistenceCoordinator.swift @@ -65,7 +65,10 @@ extension LibraryController { /// Removes all persisted library caches so tests or sign-out flows start from a clean disk state. func clearPersistedLibraryCaches() { let repositoryURL = cacheLocations.repository - let persistedCacheURLs = [ + let legacyRepositoryURL = cacheLocations.sections + .deletingPathExtension() + .appendingPathExtension("swiftdata") + var persistedCacheURLs = [ cacheLocations.details, cacheLocations.sections, repositoryURL, @@ -73,6 +76,13 @@ extension LibraryController { URL(fileURLWithPath: repositoryURL.path + "-wal"), cacheLocations.homeMerchandising ] + if legacyRepositoryURL != repositoryURL { + persistedCacheURLs.append(contentsOf: [ + legacyRepositoryURL, + URL(fileURLWithPath: legacyRepositoryURL.path + "-shm"), + URL(fileURLWithPath: legacyRepositoryURL.path + "-wal"), + ]) + } for cacheURL in persistedCacheURLs { try? FileManager.default.removeItem(at: cacheURL) diff --git a/Packages/StratixCore/Sources/StratixCore/MetadataCacheStore.swift b/Packages/StratixCore/Sources/StratixCore/MetadataCacheStore.swift index 3d619f0..aff3c3f 100644 --- a/Packages/StratixCore/Sources/StratixCore/MetadataCacheStore.swift +++ b/Packages/StratixCore/Sources/StratixCore/MetadataCacheStore.swift @@ -35,6 +35,12 @@ enum MetadataCacheStore { cachesDirectory().appendingPathComponent(filename) } + static func cacheURL(for filename: String) -> URL { + let dir = cachesDirectory().appendingPathComponent(directoryName, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent(filename) + } + private static func cachesDirectory() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] } diff --git a/Packages/StratixCore/Sources/StratixCore/StreamController.swift b/Packages/StratixCore/Sources/StratixCore/StreamController.swift index 338efbc..9635c89 100644 --- a/Packages/StratixCore/Sources/StratixCore/StreamController.swift +++ b/Packages/StratixCore/Sources/StratixCore/StreamController.swift @@ -461,8 +461,8 @@ public final class StreamController { private func makeRuntimeAttachmentEnvironment() -> StreamRuntimeAttachmentEnvironment { StreamRuntimeAttachmentEnvironment( input: StreamRuntimeInputEnvironment( - setupControllerObservation: { [weak self] inputQueue in - self?.dependencies?.inputController.setupControllerObservation(inputQueue: inputQueue) + setupControllerObservation: { [weak self] session in + self?.dependencies?.inputController.setupControllerObservation(streamingSession: session) }, clearStreamingInputBindings: { [weak self] in self?.dependencies?.inputController.clearStreamingInputBindings() diff --git a/Packages/StratixCore/Sources/StratixCore/Streaming/StreamRuntimeAttachmentService.swift b/Packages/StratixCore/Sources/StratixCore/Streaming/StreamRuntimeAttachmentService.swift index 4cd7e67..7728b5c 100644 --- a/Packages/StratixCore/Sources/StratixCore/Streaming/StreamRuntimeAttachmentService.swift +++ b/Packages/StratixCore/Sources/StratixCore/Streaming/StreamRuntimeAttachmentService.swift @@ -8,7 +8,7 @@ import InputBridge import StreamingCore struct StreamRuntimeInputEnvironment { - let setupControllerObservation: @Sendable @MainActor (InputQueue) -> Void + let setupControllerObservation: @Sendable @MainActor (any StreamingSessionFacade) -> Void let clearStreamingInputBindings: @Sendable @MainActor () -> Void let routeVibration: @Sendable @MainActor (VibrationReport) -> Void } @@ -50,7 +50,7 @@ final class StreamRuntimeAttachmentService { ] } - environment.input.setupControllerObservation(session.inputQueueRef) + environment.input.setupControllerObservation(session) session.setVibrationHandler { report in Task { @MainActor in environment.input.routeVibration(report) diff --git a/Packages/StratixCore/Tests/StratixCoreTests/Hydration/SwiftDataLibraryRepositoryTests.swift b/Packages/StratixCore/Tests/StratixCoreTests/Hydration/SwiftDataLibraryRepositoryTests.swift index 096de4e..cdfd508 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/Hydration/SwiftDataLibraryRepositoryTests.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/Hydration/SwiftDataLibraryRepositoryTests.swift @@ -62,4 +62,58 @@ struct SwiftDataLibraryRepositoryTests { #expect(await repository.loadUnifiedSectionsSnapshot() == nil) } + + @Test + func init_resetsUnreadablePersistentStoreAndRetries() async throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("swiftdata-library-repository-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + let storeURL = cacheRoot.appendingPathComponent("sections.swiftdata") + try Data("not a sqlite database".utf8).write(to: storeURL) + try Data("stale wal".utf8).write(to: URL(fileURLWithPath: storeURL.path + "-wal")) + try Data("stale shm".utf8).write(to: URL(fileURLWithPath: storeURL.path + "-shm")) + + let repository = try SwiftDataLibraryRepository(storeURL: storeURL) + let snapshot = LibraryHydrationPersistenceStore.makeUnifiedSectionsSnapshot( + sections: [TestHydrationFixtures.section(items: [TestHydrationFixtures.item(titleId: "title-1", productId: "product-1")])], + homeMerchandising: nil, + discovery: nil, + savedAt: .now, + isUnifiedHomeReady: false + ) + + await repository.saveUnifiedSectionsSnapshot(snapshot) + + let decoded = try #require(await repository.loadUnifiedSectionsSnapshot()) + #expect(decoded.sections.map(\.id) == ["library"]) + #expect(FileManager.default.fileExists(atPath: storeURL.path)) + } + + @Test + func init_createsMissingParentDirectoriesBeforeOpeningStore() async throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("swiftdata-library-repository-parent-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + let storeURL = cacheRoot + .appendingPathComponent("nested", isDirectory: true) + .appendingPathComponent("sections.swiftdata") + let repository = try SwiftDataLibraryRepository(storeURL: storeURL) + let snapshot = LibraryHydrationPersistenceStore.makeUnifiedSectionsSnapshot( + sections: [TestHydrationFixtures.section(items: [TestHydrationFixtures.item(titleId: "title-1", productId: "product-1")])], + homeMerchandising: nil, + discovery: nil, + savedAt: .now, + isUnifiedHomeReady: false + ) + + await repository.saveUnifiedSectionsSnapshot(snapshot) + + let decoded = try #require(await repository.loadUnifiedSectionsSnapshot()) + #expect(decoded.sections.map(\.id) == ["library"]) + #expect(FileManager.default.fileExists(atPath: storeURL.deletingLastPathComponent().path)) + #expect(FileManager.default.fileExists(atPath: storeURL.path)) + } } diff --git a/Packages/StratixCore/Tests/StratixCoreTests/LibraryControllerTests.swift b/Packages/StratixCore/Tests/StratixCoreTests/LibraryControllerTests.swift index 90443cd..4cdc8c4 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/LibraryControllerTests.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/LibraryControllerTests.swift @@ -243,13 +243,15 @@ struct LibraryControllerTests { defer { try? FileManager.default.removeItem(at: cacheRoot) } let sectionsURL = cacheRoot.appendingPathComponent("sections.json") + let repositoryURL = cacheRoot.appendingPathComponent("sections.swiftdata") let snapshot = makeUnifiedSectionsSnapshot(savedAt: Date()) - let repository = try SwiftDataLibraryRepository(storeURL: sectionsURL) + let repository = try SwiftDataLibraryRepository(storeURL: repositoryURL) await repository.saveUnifiedSectionsSnapshot(snapshot) let worker = LibraryHydrationWorker( detailsCacheURL: cacheRoot.appendingPathComponent("details.json"), sectionsCacheURL: sectionsURL, + repositoryStoreURL: repositoryURL, homeMerchandisingCacheURL: cacheRoot.appendingPathComponent("home.json") ) diff --git a/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceMetadataTests.swift b/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceMetadataTests.swift index b8b9851..90b597e 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceMetadataTests.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceMetadataTests.swift @@ -146,7 +146,10 @@ struct LibraryHydrationPersistenceMetadataTests { private func makeRepository(cacheRoot: URL) throws -> SwiftDataLibraryRepository { try SwiftDataLibraryRepository( - storeURL: cacheRoot.appendingPathComponent("sections.json") + storeURL: cacheRoot + .appendingPathComponent("sections.json") + .deletingPathExtension() + .appendingPathExtension("swiftdata") ) } } diff --git a/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceTests.swift b/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceTests.swift index e1b313f..46bdaa8 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceTests.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/LibraryHydrationPersistenceTests.swift @@ -172,7 +172,10 @@ struct LibraryHydrationPersistenceTests { private func makeRepository(cacheRoot: URL) throws -> SwiftDataLibraryRepository { try SwiftDataLibraryRepository( - storeURL: cacheRoot.appendingPathComponent("sections.json") + storeURL: cacheRoot + .appendingPathComponent("sections.json") + .deletingPathExtension() + .appendingPathExtension("swiftdata") ) } diff --git a/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift b/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift index 40ce727..82dbf54 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift @@ -6,6 +6,7 @@ import Testing @testable import StratixCore import StratixModels import InputBridge +import StreamingCore @MainActor @Suite(.serialized) @@ -14,19 +15,19 @@ struct StreamRuntimeAttachmentServiceTests { func attach_session_returnsAttachedActionsAndWiresInputAndVibration() async { let service = StreamRuntimeAttachmentService() let session = makeStreamingSession() - var observedInputQueue = false + var observedSession: (any StreamingSessionFacade)? var vibrationReports: [VibrationReport] = [] let actions = service.attach( session: session, environment: makeRuntimeAttachmentEnvironment( - setupControllerObservation: { _ in observedInputQueue = true }, + setupControllerObservation: { observedSession = $0 }, routeVibration: { vibrationReports.append($0) } ), onLifecycleChange: { _ in } ) - #expect(observedInputQueue == true) + #expect(observedSession === session) #expect(actions.contains(StreamAction.streamingSessionSet(session))) #expect(actions.contains(StreamAction.sessionAttachmentStateSet(.attached))) diff --git a/Packages/StratixCore/Tests/StratixCoreTests/StreamTestSupport.swift b/Packages/StratixCore/Tests/StratixCoreTests/StreamTestSupport.swift index 51531e0..c1bab35 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/StreamTestSupport.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/StreamTestSupport.swift @@ -74,7 +74,7 @@ func makeHeroArtworkEnvironment( } func makeRuntimeAttachmentEnvironment( - setupControllerObservation: @escaping @MainActor (InputQueue) -> Void = { _ in }, + setupControllerObservation: @escaping @MainActor (any StreamingSessionFacade) -> Void = { _ in }, clearStreamingInputBindings: @escaping @MainActor () -> Void = {}, routeVibration: @escaping @MainActor (VibrationReport) -> Void = { _ in } ) -> StreamRuntimeAttachmentEnvironment { diff --git a/Packages/StreamingCore/Sources/StreamingCore/Channels/InputChannel.swift b/Packages/StreamingCore/Sources/StreamingCore/Channels/InputChannel.swift index 80dc5f2..be74bf0 100644 --- a/Packages/StreamingCore/Sources/StreamingCore/Channels/InputChannel.swift +++ b/Packages/StreamingCore/Sources/StreamingCore/Channels/InputChannel.swift @@ -23,6 +23,7 @@ private struct InputChannelState: Sendable { var onServerMetadata: InputChannel.ServerMetadataHandler? var onFlushTelemetry: InputChannel.FlushTelemetryHandler? var shouldLogRawInboundMetadata: InputChannel.RawInboundMetadataLogger? + var shouldLogRawOutboundPackets: InputChannel.RawOutboundPacketLogger? var lifecycleGeneration: UInt64 = 0 var didStart = false @@ -51,6 +52,7 @@ private struct InputChannelState: Sendable { var blockedSendTickCountThisSecond = 0 var maxSendInFlightMsThisSecond: Double = 0 var lastSendHealthLogTimestampMs: Double = 0 + var rawOutboundPacketLogsEmitted = 0 } public final class InputChannel: Sendable { @@ -61,6 +63,7 @@ public final class InputChannel: Sendable { public typealias ServerMetadataHandler = @Sendable (UInt32, UInt32) -> Void public typealias FlushTelemetryHandler = @Sendable (Double, Double) -> Void public typealias RawInboundMetadataLogger = @Sendable () -> Bool + public typealias RawOutboundPacketLogger = @Sendable () -> Bool private let queue: InputQueue private let state: OSAllocatedUnfairLock @@ -87,13 +90,15 @@ public final class InputChannel: Sendable { onVibration: VibrationHandler? = nil, onServerMetadata: ServerMetadataHandler? = nil, onFlushTelemetry: FlushTelemetryHandler? = nil, - shouldLogRawInboundMetadata: RawInboundMetadataLogger? = nil + shouldLogRawInboundMetadata: RawInboundMetadataLogger? = nil, + shouldLogRawOutboundPackets: RawOutboundPacketLogger? = nil ) { state.withLock { state in state.onVibration = onVibration state.onServerMetadata = onServerMetadata state.onFlushTelemetry = onFlushTelemetry state.shouldLogRawInboundMetadata = shouldLogRawInboundMetadata + state.shouldLogRawOutboundPackets = shouldLogRawOutboundPackets } } @@ -107,6 +112,7 @@ public final class InputChannel: Sendable { logVerbose("[InputChannel:\(instanceID)] onOpen: sending client metadata and starting loop") let metadata = queue.makeInitialMetadata() + maybeLogRawOutboundPacket(metadata) let bridge = bridgeSnapshot() try? await bridge?.send(channelKind: .input, data: metadata) @@ -167,6 +173,7 @@ public final class InputChannel: Sendable { } private func sendPacket(_ packet: Data) async -> String? { + maybeLogRawOutboundPacket(packet) do { try await bridgeSnapshot()?.send(channelKind: .input, data: packet) return nil @@ -273,6 +280,7 @@ public final class InputChannel: Sendable { state.onServerMetadata = nil state.onFlushTelemetry = nil state.shouldLogRawInboundMetadata = nil + state.shouldLogRawOutboundPackets = nil state.didLogFirstPacketQueued = false state.didCaptureLoopExecutionContext = false state.loopExecutionLabel = "unresolved" @@ -293,6 +301,7 @@ public final class InputChannel: Sendable { state.blockedSendTickCountThisSecond = 0 state.maxSendInFlightMsThisSecond = 0 state.lastSendHealthLogTimestampMs = 0 + state.rawOutboundPacketLogsEmitted = 0 return loopTask } loopTask?.cancel() @@ -346,6 +355,66 @@ public final class InputChannel: Sendable { return logger?() == true } + private func shouldLogRawOutboundPacket() -> Bool { + let logger = state.withLock { state -> RawOutboundPacketLogger? in + guard state.didStart else { return nil } + guard state.rawOutboundPacketLogsEmitted < 12 else { return nil } + return state.shouldLogRawOutboundPackets + } + guard logger?() == true else { return false } + state.withLock { state in + guard state.didStart, state.rawOutboundPacketLogsEmitted < 12 else { return } + state.rawOutboundPacketLogsEmitted += 1 + } + return true + } + + private func maybeLogRawOutboundPacket(_ packet: Data) { + guard shouldLogRawOutboundPacket() else { return } + + let bytes = packet.map { String(format: "%02x", $0) }.joined(separator: " ") + let reportType = packet.count >= 2 ? UInt16(packet[0]) | (UInt16(packet[1]) << 8) : 0 + let sequence = packet.count >= 6 + ? UInt32(packet[2]) | (UInt32(packet[3]) << 8) | (UInt32(packet[4]) << 16) | (UInt32(packet[5]) << 24) + : 0 + let summary = outboundPacketSummary(packet) + + print( + "[InputChannel:\(instanceID)][RawOutbound] reportType=0x\(String(reportType, radix: 16)) seq=\(sequence) \(summary) bytes=\(bytes)" + ) + } + + private func outboundPacketSummary(_ packet: Data) -> String { + guard packet.count >= 15 else { + return "kind=headerOnly size=\(packet.count)" + } + + let reportType = packet.count >= 2 ? UInt16(packet[0]) | (UInt16(packet[1]) << 8) : 0 + let includesClientMetadata = (reportType & ReportType.clientMetadata.rawValue) != 0 + let includesGamepad = (reportType & ReportType.gamepad.rawValue) != 0 + let includesMetadata = (reportType & ReportType.metadata.rawValue) != 0 + + if includesClientMetadata { + return "kind=clientMetadata maxTouchPoints=\(packet[14]) size=\(packet.count)" + } + + guard includesGamepad, packet.count >= 15 else { + return "kind=other size=\(packet.count) includesMetadata=\(includesMetadata)" + } + + let gamepadCount = Int(packet[14]) + guard gamepadCount > 0, packet.count >= 38 else { + return "kind=gamepad count=\(gamepadCount) size=\(packet.count) includesMetadata=\(includesMetadata)" + } + + let gamepadIndex = packet[15] + let buttonMask = UInt16(packet[16]) | (UInt16(packet[17]) << 8) + let leftTrigger = UInt16(packet[26]) | (UInt16(packet[27]) << 8) + let rightTrigger = UInt16(packet[28]) | (UInt16(packet[29]) << 8) + + return "kind=gamepad count=\(gamepadCount) index=\(gamepadIndex) mask=0x\(String(buttonMask, radix: 16)) lt=\(leftTrigger) rt=\(rightTrigger) includesMetadata=\(includesMetadata) size=\(packet.count)" + } + private func wallClockNowMs() -> Int { Int(Date().timeIntervalSince1970 * 1000) } diff --git a/Packages/StreamingCore/Sources/StreamingCore/Runtime/StreamingRuntime.swift b/Packages/StreamingCore/Sources/StreamingCore/Runtime/StreamingRuntime.swift index cff862d..e95a468 100644 --- a/Packages/StreamingCore/Sources/StreamingCore/Runtime/StreamingRuntime.swift +++ b/Packages/StreamingCore/Sources/StreamingCore/Runtime/StreamingRuntime.swift @@ -297,6 +297,9 @@ actor StreamingRuntime { }, shouldLogRawInboundMetadata: { [startupPayloadLogWindow] in startupPayloadLogWindow.shouldLog() + }, + shouldLogRawOutboundPackets: { [startupPayloadLogWindow] in + startupPayloadLogWindow.shouldLog() } ) inputChannel = channel @@ -418,6 +421,10 @@ actor StreamingRuntime { guard !pendingUpdates.isEmpty else { return } + streamLogger.info( + "Flushing pending gamepad registration updates count=\(pendingUpdates.count, privacy: .public)" + ) + for (index, connected) in pendingUpdates { await controlChannel.sendGamepadChanged(index: index, wasAdded: connected) sentGamepadConnectionState[index] = connected @@ -439,7 +446,9 @@ actor StreamingRuntime { } private func resetForConnect() { - resetTransientState() + let desiredGamepadConnectionState = self.desiredGamepadConnectionState + resetTransientState(clearDesiredGamepadConnectionState: false) + self.desiredGamepadConnectionState = desiredGamepadConnectionState startupPayloadLogWindow.reset() controlPreferredDimensions = StreamDimensions( width: Int(streamingConfig.videoDimensionsHint.width), @@ -448,7 +457,7 @@ actor StreamingRuntime { messagePreferredDimensions = streamingConfig.messageChannelDimensions } - private func resetTransientState() { + private func resetTransientState(clearDesiredGamepadConnectionState: Bool = true) { callbackGeneration &+= 1 generationBox.set(callbackGeneration) controlChannel = nil @@ -459,7 +468,9 @@ actor StreamingRuntime { controlDataChannelOpen = false inputDataChannelOpen = false controlStartupCompleted = false - desiredGamepadConnectionState = [:] + if clearDesiredGamepadConnectionState { + desiredGamepadConnectionState = [:] + } sentGamepadConnectionState = [:] negotiatedDimensions = nil inputFlushHz = nil diff --git a/Packages/StreamingCore/Tests/StreamingCoreTests/InputChannelTests.swift b/Packages/StreamingCore/Tests/StreamingCoreTests/InputChannelTests.swift index f8b6183..fddc5a4 100644 --- a/Packages/StreamingCore/Tests/StreamingCoreTests/InputChannelTests.swift +++ b/Packages/StreamingCore/Tests/StreamingCoreTests/InputChannelTests.swift @@ -103,10 +103,15 @@ struct InputChannelTests { shouldLogRawInboundMetadata: { recorder.noteRawMetadataLogCheck() return recorder.shouldLogRawInboundMetadata + }, + shouldLogRawOutboundPackets: { + recorder.noteRawOutboundLogCheck() + return recorder.shouldLogRawOutboundPackets } ) recorder.shouldLogRawInboundMetadata = true + recorder.shouldLogRawOutboundPackets = true await channel.onOpen() var vibration = Data(count: 13) @@ -139,6 +144,7 @@ struct InputChannelTests { #expect(receivedMetadata.0 == 1280) #expect(receivedMetadata.1 == 800) #expect(recorder.rawMetadataLogChecks == 1) + #expect(recorder.rawOutboundLogChecks >= 1) } @Test @@ -269,6 +275,11 @@ private final class CallbackRecorder: @unchecked Sendable { set { queue.sync { storage.shouldLogRawInboundMetadata = newValue } } } + var shouldLogRawOutboundPackets: Bool { + get { queue.sync { storage.shouldLogRawOutboundPackets } } + set { queue.sync { storage.shouldLogRawOutboundPackets = newValue } } + } + var vibrationReports: [VibrationReport] { queue.sync { storage.vibrationReports } } @@ -281,6 +292,10 @@ private final class CallbackRecorder: @unchecked Sendable { queue.sync { storage.rawMetadataLogChecks } } + var rawOutboundLogChecks: Int { + queue.sync { storage.rawOutboundLogChecks } + } + func recordVibration(_ report: VibrationReport) { queue.sync { storage.vibrationReports.append(report) @@ -299,10 +314,18 @@ private final class CallbackRecorder: @unchecked Sendable { } } + func noteRawOutboundLogCheck() { + queue.sync { + storage.rawOutboundLogChecks += 1 + } + } + private struct Storage { var shouldLogRawInboundMetadata = false + var shouldLogRawOutboundPackets = false var vibrationReports: [VibrationReport] = [] var serverMetadata: [(UInt32, UInt32)] = [] var rawMetadataLogChecks = 0 + var rawOutboundLogChecks = 0 } } diff --git a/Stratix.xcworkspace/xcshareddata/xcschemes/Stratix-Debug.xcscheme b/Stratix.xcworkspace/xcshareddata/xcschemes/Stratix-Debug.xcscheme index b5803bb..456998e 100644 --- a/Stratix.xcworkspace/xcshareddata/xcschemes/Stratix-Debug.xcscheme +++ b/Stratix.xcworkspace/xcshareddata/xcschemes/Stratix-Debug.xcscheme @@ -55,14 +55,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "NO"> - - - - + shouldUseLaunchSchemeArgsEnv = "YES"> + + + +