From c8c86f760dd1d3e3e5afbf77402f267ec9ddb4bd Mon Sep 17 00:00:00 2001 From: nafields Date: Thu, 9 Apr 2026 11:23:15 -0400 Subject: [PATCH 1/2] Improve SwiftData repo init and controller input handling Add configurable SwiftData repository store path and robust initialization: SwiftDataLibraryRepository gains makeModelContainer to create parent directories, handle unreadable/incompatible on-disk stores by removing sqlite artifacts (-shm/-wal) and retrying, and helper methods to ensure directories. LibraryHydrationWorker and LibraryController now accept/produce an explicit repository store URL. MetadataCacheStore.cacheURL ensures cache directories exist. LibraryPersistenceCoordinator clears legacy swiftdata artifacts when wiping caches. Refactor input plumbing to use StreamingSessionFacade: StreamRuntimeAttachmentService and StreamController pass the session instead of an InputQueue, and InputController now accepts a session, reports gamepad connection state via a session callback, and runs a MainActor polling task to enqueue gamepad frames continuously (with idle/frame suppression logic). Tests updated and new SwiftDataLibraryRepository tests added for unreadable-store reset and parent directory creation. --- .../Hydration/LibraryHydrationWorker.swift | 5 +- .../SwiftDataLibraryRepository.swift | 84 +++++++++++++++---- .../Sources/StratixCore/InputController.swift | 66 ++++++++++++--- .../StratixCore/LibraryController.swift | 18 +++- .../LibraryPersistenceCoordinator.swift | 12 ++- .../StratixCore/MetadataCacheStore.swift | 6 ++ .../StratixCore/StreamController.swift | 4 +- .../StreamRuntimeAttachmentService.swift | 4 +- .../SwiftDataLibraryRepositoryTests.swift | 54 ++++++++++++ .../LibraryControllerTests.swift | 4 +- .../StreamRuntimeAttachmentServiceTests.swift | 7 +- .../StratixCoreTests/StreamTestSupport.swift | 2 +- 12 files changed, 228 insertions(+), 38 deletions(-) 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..10f540e 100644 --- a/Packages/StratixCore/Sources/StratixCore/InputController.swift +++ b/Packages/StratixCore/Sources/StratixCore/InputController.swift @@ -120,7 +120,9 @@ public final class InputController { @ObservationIgnored private var startupHapticsProbeControllers: Set = [] @ObservationIgnored private var didRunAppLaunchHapticsProbe = false @ObservationIgnored private var hapticsProbeTask: Task? + @ObservationIgnored private var controllerPollingTask: Task? @ObservationIgnored private var activeInputQueue: InputQueue? + @ObservationIgnored private var reportGamepadConnectionState: (@MainActor (Int, Bool) -> Void)? @ObservationIgnored private var didConfigureControllerObservers = false init() {} @@ -152,8 +154,12 @@ public final class InputController { settingsStore.diagnostics.startupHapticsProbeEnabled } - func setupControllerObservation(inputQueue: InputQueue) { - activeInputQueue = inputQueue + func setupControllerObservation(session: any StreamingSessionFacade) { + activeInputQueue = session.inputQueueRef + reportGamepadConnectionState = { [weak session] index, connected in + session?.setGamepadConnectionState(index: index, connected: connected) + } + startControllerPollingIfNeeded() for controller in GCController.controllers() { attachController(controller) @@ -276,7 +282,10 @@ public final class InputController { } func clearStreamingInputBindings() { + controllerPollingTask?.cancel() + controllerPollingTask = nil activeInputQueue = nil + reportGamepadConnectionState = nil } func resetForSignOut() { @@ -345,7 +354,6 @@ public final class InputController { if comboInterpreter.suppressesPrimaryInput { previousAPressed = aPressed previousBPressed = bPressed - queue.enqueueGamepadFrame(handler.idleFrame()) return } @@ -360,7 +368,6 @@ public final class InputController { previousAPressed = aPressed previousBPressed = bPressed - queue.enqueueGamepadFrame(handler.idleFrame()) return } @@ -377,16 +384,56 @@ public final class InputController { self.logger.info("[INPUT] Chord fired: LB+RB → toggleStatsHUD") self.dependencies?.toggleStatsHUD() } - queue.enqueueGamepadFrame(handler.idleFrame()) return } - queue.enqueueGamepadFrame(frame) } - dependencies?.currentStreamingSession()?.setGamepadConnectionState(index: 0, connected: true) + reportGamepadConnectionState?(0, true) logger.info("Controller attached: \(controller.vendorName ?? "Unknown")") } + private func startControllerPollingIfNeeded() { + guard controllerPollingTask == nil else { return } + + controllerPollingTask = Task { @MainActor [weak self] in + guard let self else { return } + + while !Task.isCancelled { + guard let queue = self.activeInputQueue else { break } + self.enqueueCurrentControllerFrames(into: queue) + try? await Task.sleep(for: .milliseconds(8)) + } + + if self.controllerPollingTask?.isCancelled != false { + self.controllerPollingTask = nil + } + } + } + + private func enqueueCurrentControllerFrames(into queue: InputQueue) { + let settings = controllerSettings + let overlayVisible = dependencies?.isStreamOverlayVisible == true + + for (controllerID, handler) in gamepadHandlers { + guard let gamepad = handler.controller?.extendedGamepad else { continue } + + if comboInterpreters[controllerID]?.suppressesPrimaryInput == true || overlayVisible { + queue.enqueueGamepadFrame(handler.idleFrame()) + continue + } + + handler.settings = settings + let frame = handler.readFrame(from: gamepad, settings: settings) + + if frame.buttons.contains([.leftShoulder, .rightShoulder]) { + queue.enqueueGamepadFrame(handler.idleFrame()) + continue + } + + queue.enqueueGamepadFrame(frame) + } + } + private func runStartupHapticsProbeIfNeeded( handler: GamepadHandler, controller: GCController, @@ -468,10 +515,7 @@ public final class InputController { comboInterpreters.removeValue(forKey: controllerID)?.cancelAll() startupHapticsProbeControllers.remove(controllerID) let isAnyExtendedControllerConnected = GCController.controllers().contains { $0.extendedGamepad != nil } - dependencies?.currentStreamingSession()?.setGamepadConnectionState( - index: 0, - connected: isAnyExtendedControllerConnected - ) + reportGamepadConnectionState?(0, isAnyExtendedControllerConnected) } isolated deinit { 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/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..1a082cc 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(session: 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/StreamRuntimeAttachmentServiceTests.swift b/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift index 40ce727..ab40a6b 100644 --- a/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift +++ b/Packages/StratixCore/Tests/StratixCoreTests/StreamRuntimeAttachmentServiceTests.swift @@ -15,18 +15,23 @@ struct StreamRuntimeAttachmentServiceTests { let service = StreamRuntimeAttachmentService() let session = makeStreamingSession() var observedInputQueue = false + var observedSessionIdentity: ObjectIdentifier? var vibrationReports: [VibrationReport] = [] let actions = service.attach( session: session, environment: makeRuntimeAttachmentEnvironment( - setupControllerObservation: { _ in observedInputQueue = true }, + setupControllerObservation: { observedSession in + observedInputQueue = true + observedSessionIdentity = ObjectIdentifier(observedSession) + }, routeVibration: { vibrationReports.append($0) } ), onLifecycleChange: { _ in } ) #expect(observedInputQueue == true) + #expect(observedSessionIdentity == ObjectIdentifier(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 { From 6639d825ea7a4e5e26ded831cbee9213f3992c22 Mon Sep 17 00:00:00 2001 From: nafields Date: Thu, 9 Apr 2026 13:19:12 -0400 Subject: [PATCH 2/2] Log outbound input packets; bind controllers to session Replace controller polling with an explicit active streaming session binding and simplify controller observation: store the active StreamingSessionFacade, use its inputQueue for injected frames, and call setGamepadConnectionState on the session instead of an external callback. Remove the controller polling loop and related enqueue logic, and enqueue idle frames in suppressing branches to avoid sending unwanted input. Add raw outbound input packet logging to InputChannel (limited emission count, hex dump and concise summary extraction) and wire the new shouldLogRawOutboundPackets hook through StreamingRuntime and tests. Also add a diagnostic log when flushing pending gamepad registration updates and make resetTransientState optionally preserve desiredGamepadConnectionState across a reconnect. Adjust SwiftData repository initialization to use a .swiftdata store URL (delete original extension and append .swiftdata) and update tests and call sites to match the parameter label change for setupControllerObservation. Minor Xcode scheme XML reorder for TestPlans. Temporary patches Co-Authored-By: nafields <2613057+nafields@users.noreply.github.com> --- .../Sources/StratixCore/InputController.swift | 73 ++++--------------- .../LibraryHydrationPersistence.swift | 5 +- .../StratixCore/StreamController.swift | 2 +- ...aryHydrationPersistenceMetadataTests.swift | 5 +- .../LibraryHydrationPersistenceTests.swift | 5 +- .../StreamRuntimeAttachmentServiceTests.swift | 12 +-- .../StreamingCore/Channels/InputChannel.swift | 71 +++++++++++++++++- .../Runtime/StreamingRuntime.swift | 17 ++++- .../InputChannelTests.swift | 23 ++++++ .../xcschemes/Stratix-Debug.xcscheme | 15 ++-- 10 files changed, 147 insertions(+), 81 deletions(-) diff --git a/Packages/StratixCore/Sources/StratixCore/InputController.swift b/Packages/StratixCore/Sources/StratixCore/InputController.swift index 10f540e..98f5ae5 100644 --- a/Packages/StratixCore/Sources/StratixCore/InputController.swift +++ b/Packages/StratixCore/Sources/StratixCore/InputController.swift @@ -120,9 +120,8 @@ public final class InputController { @ObservationIgnored private var startupHapticsProbeControllers: Set = [] @ObservationIgnored private var didRunAppLaunchHapticsProbe = false @ObservationIgnored private var hapticsProbeTask: Task? - @ObservationIgnored private var controllerPollingTask: Task? + @ObservationIgnored private var activeStreamingSession: (any StreamingSessionFacade)? @ObservationIgnored private var activeInputQueue: InputQueue? - @ObservationIgnored private var reportGamepadConnectionState: (@MainActor (Int, Bool) -> Void)? @ObservationIgnored private var didConfigureControllerObservers = false init() {} @@ -154,12 +153,9 @@ public final class InputController { settingsStore.diagnostics.startupHapticsProbeEnabled } - func setupControllerObservation(session: any StreamingSessionFacade) { - activeInputQueue = session.inputQueueRef - reportGamepadConnectionState = { [weak session] index, connected in - session?.setGamepadConnectionState(index: index, connected: connected) - } - startControllerPollingIfNeeded() + func setupControllerObservation(streamingSession: any StreamingSessionFacade) { + activeStreamingSession = streamingSession + activeInputQueue = streamingSession.inputQueueRef for controller in GCController.controllers() { attachController(controller) @@ -218,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: [], @@ -230,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( @@ -282,10 +278,8 @@ public final class InputController { } func clearStreamingInputBindings() { - controllerPollingTask?.cancel() - controllerPollingTask = nil + activeStreamingSession = nil activeInputQueue = nil - reportGamepadConnectionState = nil } func resetForSignOut() { @@ -354,6 +348,7 @@ public final class InputController { if comboInterpreter.suppressesPrimaryInput { previousAPressed = aPressed previousBPressed = bPressed + queue.enqueueGamepadFrame(handler.idleFrame()) return } @@ -368,6 +363,7 @@ public final class InputController { previousAPressed = aPressed previousBPressed = bPressed + queue.enqueueGamepadFrame(handler.idleFrame()) return } @@ -384,56 +380,16 @@ public final class InputController { self.logger.info("[INPUT] Chord fired: LB+RB → toggleStatsHUD") self.dependencies?.toggleStatsHUD() } + queue.enqueueGamepadFrame(handler.idleFrame()) return } + queue.enqueueGamepadFrame(frame) } - reportGamepadConnectionState?(0, true) + activeStreamingSession?.setGamepadConnectionState(index: 0, connected: true) logger.info("Controller attached: \(controller.vendorName ?? "Unknown")") } - private func startControllerPollingIfNeeded() { - guard controllerPollingTask == nil else { return } - - controllerPollingTask = Task { @MainActor [weak self] in - guard let self else { return } - - while !Task.isCancelled { - guard let queue = self.activeInputQueue else { break } - self.enqueueCurrentControllerFrames(into: queue) - try? await Task.sleep(for: .milliseconds(8)) - } - - if self.controllerPollingTask?.isCancelled != false { - self.controllerPollingTask = nil - } - } - } - - private func enqueueCurrentControllerFrames(into queue: InputQueue) { - let settings = controllerSettings - let overlayVisible = dependencies?.isStreamOverlayVisible == true - - for (controllerID, handler) in gamepadHandlers { - guard let gamepad = handler.controller?.extendedGamepad else { continue } - - if comboInterpreters[controllerID]?.suppressesPrimaryInput == true || overlayVisible { - queue.enqueueGamepadFrame(handler.idleFrame()) - continue - } - - handler.settings = settings - let frame = handler.readFrame(from: gamepad, settings: settings) - - if frame.buttons.contains([.leftShoulder, .rightShoulder]) { - queue.enqueueGamepadFrame(handler.idleFrame()) - continue - } - - queue.enqueueGamepadFrame(frame) - } - } - private func runStartupHapticsProbeIfNeeded( handler: GamepadHandler, controller: GCController, @@ -515,7 +471,10 @@ public final class InputController { comboInterpreters.removeValue(forKey: controllerID)?.cancelAll() startupHapticsProbeControllers.remove(controllerID) let isAnyExtendedControllerConnected = GCController.controllers().contains { $0.extendedGamepad != nil } - reportGamepadConnectionState?(0, isAnyExtendedControllerConnected) + activeStreamingSession?.setGamepadConnectionState( + index: 0, + connected: isAnyExtendedControllerConnected + ) } isolated deinit { 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/StreamController.swift b/Packages/StratixCore/Sources/StratixCore/StreamController.swift index 1a082cc..9635c89 100644 --- a/Packages/StratixCore/Sources/StratixCore/StreamController.swift +++ b/Packages/StratixCore/Sources/StratixCore/StreamController.swift @@ -462,7 +462,7 @@ public final class StreamController { StreamRuntimeAttachmentEnvironment( input: StreamRuntimeInputEnvironment( setupControllerObservation: { [weak self] session in - self?.dependencies?.inputController.setupControllerObservation(session: session) + self?.dependencies?.inputController.setupControllerObservation(streamingSession: session) }, clearStreamingInputBindings: { [weak self] in self?.dependencies?.inputController.clearStreamingInputBindings() 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 ab40a6b..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,24 +15,19 @@ struct StreamRuntimeAttachmentServiceTests { func attach_session_returnsAttachedActionsAndWiresInputAndVibration() async { let service = StreamRuntimeAttachmentService() let session = makeStreamingSession() - var observedInputQueue = false - var observedSessionIdentity: ObjectIdentifier? + var observedSession: (any StreamingSessionFacade)? var vibrationReports: [VibrationReport] = [] let actions = service.attach( session: session, environment: makeRuntimeAttachmentEnvironment( - setupControllerObservation: { observedSession in - observedInputQueue = true - observedSessionIdentity = ObjectIdentifier(observedSession) - }, + setupControllerObservation: { observedSession = $0 }, routeVibration: { vibrationReports.append($0) } ), onLifecycleChange: { _ in } ) - #expect(observedInputQueue == true) - #expect(observedSessionIdentity == ObjectIdentifier(session)) + #expect(observedSession === session) #expect(actions.contains(StreamAction.streamingSessionSet(session))) #expect(actions.contains(StreamAction.sessionAttachmentStateSet(.attached))) 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"> + + + +