Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1066,6 +1066,7 @@ enum ApplicationLauncher {
else {
throw AppUpdaterError.invalidDownloadedBundle
}
var probe: Process?
try await launchNewInstance(
spawn: {
let process = Process()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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..<attempts {
if isReady(processIdentifier) {
try armFallback(processIdentifier)
return
terminateProbe()
for _ in 0..<attempts {
if isProbeTerminated() {
try armFallback()
return
}
try await pause()
}
throw AppUpdaterError.relaunchFailed
}
try await pause()
}
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ 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, validates the installed copy, and launches a new instance. It restores
the backup if copying, final validation, or launch fails. The old instance exits
after the new instance launches.
path, and validates that the installed copy launches. It stops the temporary
validation instance before committing the transaction and restores the backup
if copying, final validation, launch, or probe termination fails. On success,
the old instance exits and an armed fallback launches the installed app.

> [!IMPORTANT]
> Quiescing must not cause the old instance to exit. In particular, hosts that
Expand Down
47 changes: 41 additions & 6 deletions Tests/AppUpdaterTests/AppUpdaterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand Down
Loading