diff --git a/Sources/CodexerCore/CodexLauncher.swift b/Sources/CodexerCore/CodexLauncher.swift index 2a677bc..a927e23 100644 --- a/Sources/CodexerCore/CodexLauncher.swift +++ b/Sources/CodexerCore/CodexLauncher.swift @@ -836,6 +836,7 @@ public actor CodexInstanceController { private let closeTimeout: Duration private let closePollInterval: Duration private let launchValidationTimeout: Duration + private let windowPresentationTimeout: Duration private let processTreeProvider: any ProcessTreeSnapshotProviding private let processIdentitySignaler: any ProcessIdentitySignaling private let kernelStartKeyProvider: @Sendable (Int32) -> String? @@ -849,6 +850,7 @@ public actor CodexInstanceController { closeTimeout: Duration = .seconds(5), closePollInterval: Duration = .milliseconds(100), launchValidationTimeout: Duration = .seconds(2), + windowPresentationTimeout: Duration = .seconds(10), processTreeProvider: any ProcessTreeSnapshotProviding = SystemProcessTreeSnapshotProvider(), processIdentitySignaler: (any ProcessIdentitySignaling)? = nil, kernelStartKeyProvider: (@Sendable (Int32) -> String?)? = nil @@ -861,6 +863,7 @@ public actor CodexInstanceController { self.closeTimeout = closeTimeout self.closePollInterval = closePollInterval self.launchValidationTimeout = launchValidationTimeout + self.windowPresentationTimeout = windowPresentationTimeout self.processTreeProvider = processTreeProvider self.processIdentitySignaler = processIdentitySignaler ?? SystemProcessIdentitySignaler(snapshotProvider: processTreeProvider) @@ -1266,7 +1269,7 @@ public actor CodexInstanceController { private func waitForPresentedWindow(processID: Int32) async throws -> Bool { let clock = ContinuousClock() - let deadline = clock.now.advanced(by: launchValidationTimeout) + let deadline = clock.now.advanced(by: windowPresentationTimeout) while clock.now < deadline { try Task.checkCancellation() if lifecycleController.isPresentingWindow(processID: processID) { diff --git a/Tests/CodexerCoreTests/CodexLauncherTests.swift b/Tests/CodexerCoreTests/CodexLauncherTests.swift index 4c4e61d..7dde2ed 100644 --- a/Tests/CodexerCoreTests/CodexLauncherTests.swift +++ b/Tests/CodexerCoreTests/CodexLauncherTests.swift @@ -358,13 +358,14 @@ final class CodexLauncherTests: XCTestCase { func testOpenStopsFreshInstanceThatDoesNotPresentAWindow() async throws { let profile = makeProfile(slug: "windowless") try prepareIsolationLayout(for: profile) - let lifecycle = WindowlessLifecycleController(processID: 742) + let lifecycle = PresentationLifecycleController(processID: 742) let controller = CodexInstanceController( validator: AcceptingValidator(), processSnapshotProvider: FixedProcessSnapshotProvider(snapshot: ""), workspaceLauncher: RecordingWorkspaceLauncher(processID: 742), lifecycleController: lifecycle, - launchValidationTimeout: .milliseconds(1) + launchValidationTimeout: .milliseconds(1), + windowPresentationTimeout: .milliseconds(1) ) do { @@ -383,11 +384,37 @@ final class CodexLauncherTests: XCTestCase { XCTAssertEqual(lifecycle.terminatedProcessIDs, [742]) } + func testOpenAllowsWindowPresentationToOutlastLaunchValidation() async throws { + let profile = makeProfile(slug: "slow-window") + try prepareIsolationLayout(for: profile) + let lifecycle = PresentationLifecycleController( + processID: 744, + hiddenWindowChecks: 2 + ) + let controller = CodexInstanceController( + validator: AcceptingValidator(), + processSnapshotProvider: FixedProcessSnapshotProvider(snapshot: ""), + workspaceLauncher: RecordingWorkspaceLauncher(processID: 744), + lifecycleController: lifecycle, + launchValidationTimeout: .milliseconds(1), + windowPresentationTimeout: .milliseconds(250) + ) + + let outcome = try await controller.open( + profile: profile, + codexAppURL: configuration(for: profile).codexAppURL + ) + + XCTAssertEqual(outcome, .launched(processID: 744)) + XCTAssertGreaterThanOrEqual(lifecycle.windowCheckCount, 3) + XCTAssertTrue(lifecycle.terminatedProcessIDs.isEmpty) + } + func testCancellingWindowPresentationStopsFreshInstance() async throws { let profile = makeProfile(slug: "cancelled-window") try prepareIsolationLayout(for: profile) let presentationRequested = expectation(description: "presentation requested") - let lifecycle = WindowlessLifecycleController(processID: 743) { + let lifecycle = PresentationLifecycleController(processID: 743) { presentationRequested.fulfill() } let controller = CodexInstanceController( @@ -1364,14 +1391,21 @@ private final class RejectingLaunchedProcessController: CodexApplicationLifecycl } } -private final class WindowlessLifecycleController: CodexApplicationLifecycleControlling, @unchecked Sendable { +private final class PresentationLifecycleController: CodexApplicationLifecycleControlling, @unchecked Sendable { let processID: Int32 private(set) var presentationRequests: [Int32] = [] private(set) var terminatedProcessIDs: [Int32] = [] + private(set) var windowCheckCount = 0 + private let hiddenWindowChecks: Int? private let onPresentationRequested: () -> Void - init(processID: Int32, onPresentationRequested: @escaping () -> Void = {}) { + init( + processID: Int32, + hiddenWindowChecks: Int? = nil, + onPresentationRequested: @escaping () -> Void = {} + ) { self.processID = processID + self.hiddenWindowChecks = hiddenWindowChecks self.onPresentationRequested = onPresentationRequested } @@ -1392,7 +1426,9 @@ private final class WindowlessLifecycleController: CodexApplicationLifecycleCont } func isPresentingWindow(processID _: Int32) -> Bool { - false + windowCheckCount += 1 + guard let hiddenWindowChecks else { return false } + return windowCheckCount > hiddenWindowChecks } func terminate(