diff --git a/AppUpdater.swift b/AppUpdater.swift index 286fd04..a3df8b9 100644 --- a/AppUpdater.swift +++ b/AppUpdater.swift @@ -361,7 +361,7 @@ public enum AppUpdaterError: LocalizedError, Equatable { case .processFailed(let executable, let status, let stderr): "\(executable.path) failed with status \(status): \(stderr)" case .relaunchFailed: - "The updated app did not launch a new application instance." + "The updated app could not be relaunched safely." case .resourceLimitExceeded(let resource): "The update exceeded the configured \(resource) limit." case .rollbackFailed: @@ -1066,6 +1066,7 @@ enum ApplicationLauncher { else { throw AppUpdaterError.invalidDownloadedBundle } + var probe: Process? try await launchNewInstance( spawn: { let process = Process() @@ -1074,6 +1075,7 @@ enum ApplicationLauncher { process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try process.run() + probe = process return process.processIdentifier }, isReady: { processIdentifier in @@ -1085,10 +1087,13 @@ enum ApplicationLauncher { application.bundleIdentifier == identifier, application.executableURL?.resolvingSymlinksInPath() == executableURL else { return false } - application.activate() return true }, - armFallback: { _ in + terminateProbe: { + if probe?.isRunning == true { probe?.terminate() } + }, + isProbeTerminated: { probe?.isRunning == false }, + armFallback: { try armFallback( oldProcessIdentifier: getpid(), applicationURL: url @@ -1130,14 +1135,23 @@ enum ApplicationLauncher { attempts: Int = 200, spawn: () throws -> pid_t, isReady: (pid_t) -> Bool, - armFallback: (pid_t) throws -> Void = { _ in }, + terminateProbe: () -> Void, + isProbeTerminated: () -> Bool, + armFallback: () throws -> Void = {}, pause: () async throws -> Void ) async throws { let processIdentifier = try spawn() for _ in 0.. [!IMPORTANT] > Quiescing must not cause the old instance to exit. In particular, hosts that diff --git a/Tests/AppUpdaterTests/AppUpdaterTests.swift b/Tests/AppUpdaterTests/AppUpdaterTests.swift index 7e5a4f9..1793124 100644 --- a/Tests/AppUpdaterTests/AppUpdaterTests.swift +++ b/Tests/AppUpdaterTests/AppUpdaterTests.swift @@ -916,10 +916,12 @@ final class AppUpdaterTests: XCTestCase { } @MainActor - func testRelaunchWaitsForSpawnedProcess() async throws { - var samples = [false, false, true] + func testRelaunchTerminatesProbeBeforeArmingFallback() async throws { + var readiness = [false, false, true] + var termination = [false, true] + var events: [String] = [] var launches = 0 - var protectedProcessIdentifier: pid_t? + var armedFallback = false try await ApplicationLauncher.launchNewInstance( attempts: 3, spawn: { @@ -928,13 +930,24 @@ final class AppUpdaterTests: XCTestCase { }, isReady: { processIdentifier in XCTAssertEqual(processIdentifier, 42) - return samples.removeFirst() + return readiness.removeFirst() + }, + terminateProbe: { events.append("terminate probe") }, + isProbeTerminated: { + events.append("check probe") + return termination.removeFirst() + }, + armFallback: { + events.append("arm fallback") + armedFallback = true }, - armFallback: { protectedProcessIdentifier = $0 }, pause: {} ) XCTAssertEqual(launches, 1) - XCTAssertEqual(protectedProcessIdentifier, 42) + XCTAssertTrue(armedFallback) + XCTAssertEqual(events, [ + "terminate probe", "check probe", "check probe", "arm fallback", + ]) } @MainActor @@ -944,6 +957,8 @@ final class AppUpdaterTests: XCTestCase { attempts: 2, spawn: { 42 }, isReady: { _ in false }, + terminateProbe: {}, + isProbeTerminated: { false }, pause: {} ) XCTFail("launch should fail until the spawned process is ready") @@ -952,6 +967,26 @@ final class AppUpdaterTests: XCTestCase { } } + @MainActor + func testRelaunchRejectsUnstoppableProbe() async { + var armedFallback = false + do { + try await ApplicationLauncher.launchNewInstance( + attempts: 1, + spawn: { 42 }, + isReady: { _ in true }, + terminateProbe: {}, + isProbeTerminated: { false }, + armFallback: { armedFallback = true }, + pause: {} + ) + XCTFail("launch should fail while the validation process is running") + } catch { + XCTAssertEqual(error as? AppUpdaterError, .relaunchFailed) + XCTAssertFalse(armedFallback) + } + } + @MainActor func testInstalledValidationFailureRollsBack() async throws { var validations = 0