From ddfdc2c75e59f0f6a942b9f7e9129db16df6984f Mon Sep 17 00:00:00 2001 From: Max Howell Date: Fri, 31 Jul 2026 11:59:22 -0400 Subject: [PATCH 1/3] Terminate relaunch probe before committing update --- AppUpdater.swift | 22 +++++++++-- README.md | 7 ++-- Tests/AppUpdaterTests/AppUpdaterTests.swift | 43 +++++++++++++++++++-- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/AppUpdater.swift b/AppUpdater.swift index 286fd04..e7d59b5 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,9 +1087,12 @@ enum ApplicationLauncher { application.bundleIdentifier == identifier, application.executableURL?.resolvingSymlinksInPath() == executableURL else { return false } - application.activate() return true }, + terminateProbe: { + if probe?.isRunning == true { probe?.terminate() } + }, + isProbeTerminated: { probe?.isRunning == false }, armFallback: { _ in try armFallback( oldProcessIdentifier: getpid(), @@ -1130,14 +1135,23 @@ enum ApplicationLauncher { attempts: Int = 200, spawn: () throws -> pid_t, isReady: (pid_t) -> Bool, + terminateProbe: () -> Void, + isProbeTerminated: () -> Bool, armFallback: (pid_t) throws -> Void = { _ in }, 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..92fbbf5 100644 --- a/Tests/AppUpdaterTests/AppUpdaterTests.swift +++ b/Tests/AppUpdaterTests/AppUpdaterTests.swift @@ -916,8 +916,10 @@ 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? try await ApplicationLauncher.launchNewInstance( @@ -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") + protectedProcessIdentifier = $0 }, - armFallback: { protectedProcessIdentifier = $0 }, pause: {} ) XCTAssertEqual(launches, 1) XCTAssertEqual(protectedProcessIdentifier, 42) + 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: { _ in 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 From 29f3e7540f01a8e95758cc8f8f052ddd2ef19907 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Fri, 31 Jul 2026 13:26:11 -0400 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index c267b19..5570805 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,7 @@ decide not to continue. Call `installAndRelaunch()` only after the host has saved its state, stopped background work, and ceased loading bundle code or resources. The running instance moves itself to a backup, copies the validated candidate into its old -path, and validates that the installed copy launches. It stops that validation -instance before committing the transaction, restores the backup if copying, -final validation, launch, or termination fails, then relaunches after the old -instance exits. +path, and validates that the installed copy launches. It stops that temporary validation instance before committing the transaction, restores the backup if copying, final validation, launch, or probe termination fails, then relaunches (via the fallback) after the old instance exits. > [!IMPORTANT] > Quiescing must not cause the old instance to exit. In particular, hosts that From 96c3fc989b6ee6aed6303da6ea6365c495ba2cf4 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Fri, 31 Jul 2026 13:30:02 -0400 Subject: [PATCH 3/3] Remove stale relaunch probe PID --- AppUpdater.swift | 6 +++--- README.md | 5 ++++- Tests/AppUpdaterTests/AppUpdaterTests.swift | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/AppUpdater.swift b/AppUpdater.swift index e7d59b5..a3df8b9 100644 --- a/AppUpdater.swift +++ b/AppUpdater.swift @@ -1093,7 +1093,7 @@ enum ApplicationLauncher { if probe?.isRunning == true { probe?.terminate() } }, isProbeTerminated: { probe?.isRunning == false }, - armFallback: { _ in + armFallback: { try armFallback( oldProcessIdentifier: getpid(), applicationURL: url @@ -1137,7 +1137,7 @@ enum ApplicationLauncher { isReady: (pid_t) -> Bool, terminateProbe: () -> Void, isProbeTerminated: () -> Bool, - armFallback: (pid_t) throws -> Void = { _ in }, + armFallback: () throws -> Void = {}, pause: () async throws -> Void ) async throws { let processIdentifier = try spawn() @@ -1146,7 +1146,7 @@ enum ApplicationLauncher { terminateProbe() 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 92fbbf5..1793124 100644 --- a/Tests/AppUpdaterTests/AppUpdaterTests.swift +++ b/Tests/AppUpdaterTests/AppUpdaterTests.swift @@ -921,7 +921,7 @@ final class AppUpdaterTests: XCTestCase { var termination = [false, true] var events: [String] = [] var launches = 0 - var protectedProcessIdentifier: pid_t? + var armedFallback = false try await ApplicationLauncher.launchNewInstance( attempts: 3, spawn: { @@ -939,12 +939,12 @@ final class AppUpdaterTests: XCTestCase { }, armFallback: { events.append("arm fallback") - protectedProcessIdentifier = $0 + armedFallback = true }, pause: {} ) XCTAssertEqual(launches, 1) - XCTAssertEqual(protectedProcessIdentifier, 42) + XCTAssertTrue(armedFallback) XCTAssertEqual(events, [ "terminate probe", "check probe", "check probe", "arm fallback", ]) @@ -977,7 +977,7 @@ final class AppUpdaterTests: XCTestCase { isReady: { _ in true }, terminateProbe: {}, isProbeTerminated: { false }, - armFallback: { _ in armedFallback = true }, + armFallback: { armedFallback = true }, pause: {} ) XCTFail("launch should fail while the validation process is running")