From c4f543af51a49fb284b2bd871aef2d8969661be6 Mon Sep 17 00:00:00 2001 From: Lemelson <97357844+Lemelson@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:29:11 +0300 Subject: [PATCH] Fix persistent GPS watchdog false clears --- CHANGELOG.md | 20 ++ README.md | 12 +- Sources/GeoShift/AppDelegate.swift | 4 + Sources/GeoShift/AppLivenessLease.swift | 32 +++ Sources/GeoShift/AppPaths.swift | 2 + Sources/GeoShift/CommandRunner.swift | 192 +++++++++++++----- Sources/GeoShift/ContentView.swift | 3 - Sources/GeoShift/GeoShiftApp.swift | 1 + Sources/GeoShift/KeeperController.swift | 37 +++- Sources/GeoShift/KeeperError.swift | 5 + .../Resources/en.lproj/Localizable.strings | 1 + Sources/GeoShift/Resources/keeper.py | 89 +++++++- .../Resources/ru.lproj/Localizable.strings | 1 + .../GeoShiftTests/AppLivenessLeaseTests.swift | 24 +++ Tests/GeoShiftTests/CommandRunnerTests.swift | 49 +++++ Tests/Python/test_keeper.py | 95 ++++++++- docs/ARCHITECTURE.md | 14 +- version.env | 4 +- 18 files changed, 507 insertions(+), 78 deletions(-) create mode 100644 Sources/GeoShift/AppLivenessLease.swift create mode 100644 Tests/GeoShiftTests/AppLivenessLeaseTests.swift create mode 100644 Tests/GeoShiftTests/CommandRunnerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3188577..41b789d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to GeoShift are documented here. +## 1.5.1 — 2026-07-21 + +### Fixed + +- A timed-out `launchctl` status check could block the app's only monitoring task, + freeze the UI on “Simulation active,” and stop its heartbeat. +- The five-minute crash watchdog could consequently restore real GPS while the + GeoShift window was still open and Start was still selected. +- Command timeouts now return promptly, terminate the child process, and cannot + strand later commands behind an uncancellable synchronous wait. +- Worker crash detection now requires both a stale heartbeat and a released + process-liveness lease, so an alive but delayed or suspended app cannot trigger + an unwanted GPS restore. +- The liveness lease now passes a decoded Application Support path to the POSIX + lock API instead of treating the encoded `%20` path as a real directory. +- Start retries a liveness-lease acquisition after a concurrent crash cleanup + finishes, without requiring the app to be relaunched. +- Monitoring is owned by the app controller and refreshes immediately when the + app becomes active instead of depending on a single view task. + ## 1.5.0 — 2026-07-20 ### Added diff --git a/README.md b/README.md index de5cd9b..acb4d88 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

CI - Version 1.5.0 + Version 1.5.1 macOS 14+ Swift 6.2 MIT License @@ -122,10 +122,12 @@ The pairing permission does not need weekly or monthly renewal. See - **Choose city** searches English and Russian city, country, and region names. - **Settings** controls language, reconnect timing, GPS refresh, and Wi-Fi pairing. -GeoShift writes an application heartbeat every 30 seconds. If the app crashes or -is killed, a missing heartbeat converts the active request into Restore GPS within -five minutes. Command-Q is refused when the app cannot confirm either a completed -clear or a durable handoff to the restore worker. +GeoShift writes an application heartbeat every 30 seconds and holds a kernel-owned +liveness lease for the lifetime of the GUI process. A delayed UI task or App Nap +cannot be mistaken for a crash: after a stale heartbeat, the worker restores real +GPS only when it can also prove that the GeoShift process is gone. Command-Q is +refused when the app cannot confirm either a completed clear or a durable handoff +to the restore worker. The UI reports **Simulation cleared** only after the no-reply `clear` command returns successfully through the connected iPhone's developer channel. Apple's diff --git a/Sources/GeoShift/AppDelegate.swift b/Sources/GeoShift/AppDelegate.swift index 36e7957..06a51a2 100644 --- a/Sources/GeoShift/AppDelegate.swift +++ b/Sources/GeoShift/AppDelegate.swift @@ -4,6 +4,10 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { var controller: KeeperController? + func applicationDidBecomeActive(_ notification: Notification) { + controller?.refreshAfterActivation() + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } diff --git a/Sources/GeoShift/AppLivenessLease.swift b/Sources/GeoShift/AppLivenessLease.swift new file mode 100644 index 0000000..3135eed --- /dev/null +++ b/Sources/GeoShift/AppLivenessLease.swift @@ -0,0 +1,32 @@ +import Darwin +import Foundation + +final class AppLivenessLease { + private let descriptor: Int32 + + init?(url: URL) { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let descriptor = open( + url.path(percentEncoded: false), + O_CREAT | O_RDWR | O_CLOEXEC, + S_IRUSR | S_IWUSR + ) + guard descriptor >= 0 else { + return nil + } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + close(descriptor) + return nil + } + self.descriptor = descriptor + } + + deinit { + flock(descriptor, LOCK_UN) + close(descriptor) + } +} diff --git a/Sources/GeoShift/AppPaths.swift b/Sources/GeoShift/AppPaths.swift index 5cbaf1d..ffa7580 100644 --- a/Sources/GeoShift/AppPaths.swift +++ b/Sources/GeoShift/AppPaths.swift @@ -10,6 +10,8 @@ enum AppPaths { static let pairingStatusURL = applicationSupportURL.appending(path: "pairing-status.json") + static let appLivenessLockURL = applicationSupportURL.appending(path: "gui-liveness.lock") + static let pairingRecordsURL = FileManager.default.homeDirectoryForCurrentUser .appending(path: ".pymobiledevice3") diff --git a/Sources/GeoShift/CommandRunner.swift b/Sources/GeoShift/CommandRunner.swift index 2571aa8..1d91475 100644 --- a/Sources/GeoShift/CommandRunner.swift +++ b/Sources/GeoShift/CommandRunner.swift @@ -1,29 +1,147 @@ @preconcurrency import Foundation private final class CommandExecution: @unchecked Sendable { - let process = Process() - let outputPipe = Pipe() - - func waitForResult() -> CommandResult { - process.waitUntilExit() - let data = outputPipe.fileHandleForReading.readDataToEndOfFile() - return CommandResult( - status: process.terminationStatus, - output: String(decoding: data, as: UTF8.self) + private let stateLock = NSLock() + private let process = Process() + private let outputURL: URL + private let outputHandle: FileHandle + private var continuation: CheckedContinuation? + private var timeoutTask: Task? + private var didFinish = false + private var didCleanUp = false + + init?() { + outputURL = FileManager.default.temporaryDirectory + .appending(path: "GeoShift-command-\(UUID().uuidString).log") + guard FileManager.default.createFile( + atPath: outputURL.path(), + contents: nil, + attributes: [.posixPermissions: 0o600] + ), let handle = try? FileHandle(forWritingTo: outputURL) else { + return nil + } + outputHandle = handle + process.standardOutput = handle + process.standardError = handle + } + + func run( + executable: String, + arguments: [String], + timeoutSeconds: Double + ) async -> CommandResult { + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + return await withCheckedContinuation { continuation in + stateLock.lock() + self.continuation = continuation + stateLock.unlock() + + process.terminationHandler = { [weak self] process in + self?.processDidTerminate(status: process.terminationStatus) + } + + do { + try process.run() + } catch { + finish( + CommandResult(status: -1, output: error.localizedDescription), + processHasTerminated: true + ) + cleanUpOutput() + return + } + + let task = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(timeoutSeconds)) + } catch { + return + } + self?.timeOut(after: timeoutSeconds) + } + + stateLock.lock() + if didFinish { + task.cancel() + } else { + timeoutTask = task + } + stateLock.unlock() + } + } + + private func processDidTerminate(status: Int32) { + let output = readOutputAndCleanUp() + finish( + CommandResult(status: status, output: output), + processHasTerminated: true ) } - func terminate() { - guard process.isRunning else { + private func timeOut(after seconds: Double) { + let result = CommandResult( + status: -2, + output: "The command timed out after \(Int(seconds)) seconds." + ) + guard finish(result, processHasTerminated: false) else { return } - process.terminate() - } - func killIfNeeded() { + process.terminate() if process.isRunning { kill(process.processIdentifier, SIGKILL) } + cleanUpOutput() + } + + @discardableResult + private func finish( + _ result: CommandResult, + processHasTerminated: Bool + ) -> Bool { + let continuation: CheckedContinuation? + let timeoutTask: Task? + + stateLock.lock() + if didFinish { + stateLock.unlock() + if processHasTerminated { + cleanUpOutput() + } + return false + } + didFinish = true + continuation = self.continuation + self.continuation = nil + timeoutTask = self.timeoutTask + self.timeoutTask = nil + stateLock.unlock() + + timeoutTask?.cancel() + continuation?.resume(returning: result) + return true + } + + private func readOutputAndCleanUp() -> String { + stateLock.lock() + if didCleanUp { + stateLock.unlock() + return "" + } + didCleanUp = true + stateLock.unlock() + + try? outputHandle.synchronize() + try? outputHandle.close() + let data = (try? Data(contentsOf: outputURL)) ?? Data() + try? FileManager.default.removeItem(at: outputURL) + return String(decoding: data, as: UTF8.self) + } + + private func cleanUpOutput() { + _ = readOutputAndCleanUp() } } @@ -33,44 +151,16 @@ actor CommandRunner { arguments: [String], timeoutSeconds: Double = 5 ) async -> CommandResult { - let execution = CommandExecution() - execution.process.executableURL = URL(fileURLWithPath: executable) - execution.process.arguments = arguments - execution.process.standardOutput = execution.outputPipe - execution.process.standardError = execution.outputPipe - - do { - try execution.process.run() - } catch { - return CommandResult(status: -1, output: error.localizedDescription) - } - - return await withTaskGroup(of: CommandResult.self) { group in - group.addTask { - execution.waitForResult() - } - group.addTask { - do { - try await Task.sleep(for: .seconds(timeoutSeconds)) - } catch { - return CommandResult(status: -2, output: "The command was cancelled.") - } - - execution.terminate() - try? await Task.sleep(for: .seconds(2)) - execution.killIfNeeded() - return CommandResult( - status: -2, - output: "The command timed out after \(Int(timeoutSeconds)) seconds." - ) - } - - let result = await group.next() ?? CommandResult( - status: -2, - output: "The command returned no result." + guard let execution = CommandExecution() else { + return CommandResult( + status: -1, + output: "Could not create a temporary command output file." ) - group.cancelAll() - return result } + return await execution.run( + executable: executable, + arguments: arguments, + timeoutSeconds: timeoutSeconds + ) } } diff --git a/Sources/GeoShift/ContentView.swift b/Sources/GeoShift/ContentView.swift index 618a4ec..4c8446f 100644 --- a/Sources/GeoShift/ContentView.swift +++ b/Sources/GeoShift/ContentView.swift @@ -36,9 +36,6 @@ struct ContentView: View { } .padding(24) } - .task { - await controller.poll() - } .sheet(isPresented: $isCityPickerPresented) { CityPickerView(controller: controller) } diff --git a/Sources/GeoShift/GeoShiftApp.swift b/Sources/GeoShift/GeoShiftApp.swift index 78820c2..b7191e3 100644 --- a/Sources/GeoShift/GeoShiftApp.swift +++ b/Sources/GeoShift/GeoShiftApp.swift @@ -20,6 +20,7 @@ struct GeoShiftApp: App { .frame(minWidth: 680, minHeight: 680) .onAppear { appDelegate.controller = controller + controller.startMonitoring() } } .defaultSize(width: 760, height: 820) diff --git a/Sources/GeoShift/KeeperController.swift b/Sources/GeoShift/KeeperController.swift index a11c7be..1d8ffe9 100644 --- a/Sources/GeoShift/KeeperController.swift +++ b/Sources/GeoShift/KeeperController.swift @@ -10,11 +10,13 @@ final class KeeperController { private let localization: LocalizationStore private let runner = CommandRunner() private let pairingRunner = CommandRunner() + private var appLivenessLease: AppLivenessLease? private let label = "com.lemelson.geoshift.keeper" private var didRecoverOnLaunch = false private var lastHeartbeatWrite = 0.0 private var lastWatchdogCheck = 0.0 private var lastLogRefresh = 0.0 + @ObservationIgnored private var monitoringTask: Task? var state: KeeperState = .working var detail: String @@ -54,6 +56,7 @@ final class KeeperController { init(localization: LocalizationStore) { self.localization = localization + appLivenessLease = AppLivenessLease(url: AppPaths.appLivenessLockURL) let saved = ConfigStore.load() selectedCity = saved.flatMap { CityCatalog.city(withID: $0.cityID) } ?? CityCatalog.defaultCity retrySeconds = saved?.retrySeconds ?? 5 @@ -109,7 +112,25 @@ final class KeeperController { ) } - func poll() async { + func startMonitoring() { + guard monitoringTask == nil else { + return + } + monitoringTask = Task { [weak self] in + guard let self else { + return + } + await monitor() + monitoringTask = nil + } + } + + func refreshAfterActivation() { + startMonitoring() + Task { await refreshStatus() } + } + + private func monitor() async { await recoverOnLaunch() while !Task.isCancelled { @@ -134,6 +155,14 @@ final class KeeperController { } func installAndStart() async { + if appLivenessLease == nil { + appLivenessLease = AppLivenessLease(url: AppPaths.appLivenessLockURL) + } + guard appLivenessLease != nil else { + state = .failed + errorMessage = localizedDescription(for: KeeperError.missingLivenessLease) + return + } await transition( simulationEnabled: true, message: localization.text("controller.startingTunnel") @@ -255,8 +284,7 @@ final class KeeperController { didRecoverOnLaunch = true // Keep the on-disk LaunchAgent definition current even when the last - // A successful clear send is already recorded and no worker needs to - // run right now. + // successful clear send is already recorded and no worker needs to run. if ConfigStore.load() != nil { do { try installLaunchAgent() @@ -407,6 +435,9 @@ final class KeeperController { requestID: String? = nil, heartbeatAt: Double? = nil ) throws -> KeeperConfiguration { + if simulationEnabled, appLivenessLease == nil { + throw KeeperError.missingLivenessLease + } let heartbeat = simulationEnabled ? heartbeatAt ?? Date.now.timeIntervalSince1970 : nil diff --git a/Sources/GeoShift/KeeperError.swift b/Sources/GeoShift/KeeperError.swift index 5fbae5c..bc7debd 100644 --- a/Sources/GeoShift/KeeperError.swift +++ b/Sources/GeoShift/KeeperError.swift @@ -4,6 +4,7 @@ enum KeeperError: LocalizedError { case commandFailed(String) case missingDependency(String) case missingKeeper + case missingLivenessLease var errorDescription: String? { switch self { @@ -13,6 +14,8 @@ enum KeeperError: LocalizedError { "pymobiledevice3 was not found: \(path)" case .missingKeeper: "keeper.py is missing from the app bundle." + case .missingLivenessLease: + "GeoShift could not acquire its process-liveness lock." } } @@ -28,6 +31,8 @@ enum KeeperError: LocalizedError { "\(localization.text("error.missingDependency")): \(path)" case .missingKeeper: localization.text("error.missingKeeper") + case .missingLivenessLease: + localization.text("error.missingLivenessLease") } } } diff --git a/Sources/GeoShift/Resources/en.lproj/Localizable.strings b/Sources/GeoShift/Resources/en.lproj/Localizable.strings index 2320d13..4e5590f 100644 --- a/Sources/GeoShift/Resources/en.lproj/Localizable.strings +++ b/Sources/GeoShift/Resources/en.lproj/Localizable.strings @@ -99,3 +99,4 @@ "error.previousOperation" = "The previous operation failed. Try again or open Diagnostics for connection details."; "error.missingDependency" = "pymobiledevice3 was not found"; "error.missingKeeper" = "keeper.py is missing from the app bundle."; +"error.missingLivenessLease" = "GeoShift could not start safely because another instance or cleanup operation owns its liveness lock. Close the other instance and try again."; diff --git a/Sources/GeoShift/Resources/keeper.py b/Sources/GeoShift/Resources/keeper.py index 5d23e24..0612408 100644 --- a/Sources/GeoShift/Resources/keeper.py +++ b/Sources/GeoShift/Resources/keeper.py @@ -3,6 +3,8 @@ import argparse import asyncio +import errno +import fcntl import json import logging import math @@ -26,6 +28,7 @@ APP_SUPPORT_PATH = Path.home() / "Library/Application Support/GeoShift" CONFIG_PATH = APP_SUPPORT_PATH / "config.json" STATUS_PATH = APP_SUPPORT_PATH / "status.json" +APP_LIVENESS_LOCK_PATH = APP_SUPPORT_PATH / "gui-liveness.lock" LOG_PATH = Path( os.environ.get( "GEOSHIFT_LOG_PATH", @@ -49,6 +52,23 @@ class AmbiguousDeviceError(RuntimeError): pass +class AppCleanupLease: + def __init__(self, descriptor: int): + self.descriptor = descriptor + + def close(self) -> None: + if self.descriptor < 0: + return + with suppress(OSError): + fcntl.flock(self.descriptor, fcntl.LOCK_UN) + with suppress(OSError): + os.close(self.descriptor) + self.descriptor = -1 + + def __del__(self): + self.close() + + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, @@ -124,9 +144,7 @@ def load_config(path: Path | None = None) -> dict: } -def simulation_is_requested(config: dict, now: float | None = None) -> bool: - if not config.get("simulationEnabled", False): - return False +def heartbeat_is_fresh(config: dict, now: float | None = None) -> bool: heartbeat = config.get("appHeartbeatAt") if heartbeat is None: return False @@ -135,6 +153,50 @@ def simulation_is_requested(config: dict, now: float | None = None) -> bool: return -APP_HEARTBEAT_FUTURE_TOLERANCE_SECONDS <= age <= APP_HEARTBEAT_TIMEOUT_SECONDS +def acquire_app_cleanup_lease( + path: Path | None = None, +) -> tuple[str, AppCleanupLease | None]: + path = path or APP_LIVENESS_LOCK_PATH + try: + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(path, flags, 0o600) + os.chmod(path, 0o600) + except OSError as error: + logger.warning("Could not probe GeoShift GUI liveness: %s", error) + return "unknown", None + + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + os.close(descriptor) + if error.errno in (errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK): + return "alive", None + logger.warning("Could not lock GeoShift GUI liveness file: %s", error) + return "unknown", None + return "dead", AppCleanupLease(descriptor) + + +def simulation_request_decision( + config: dict, + cleanup_lease: AppCleanupLease | None = None, + now: float | None = None, +) -> tuple[bool, AppCleanupLease | None]: + if not config.get("simulationEnabled", False): + return False, cleanup_lease + if cleanup_lease is not None: + return False, cleanup_lease + if heartbeat_is_fresh(config, now=now): + return True, None + + liveness, cleanup_lease = acquire_app_cleanup_lease() + if liveness == "dead": + return False, cleanup_lease + # An unknown lock result is not proof that the GUI died. Preserve the + # explicit Start request and retry instead of clearing a live simulation. + return True, None + + def config_version(path: Path | None = None) -> int: path = path or CONFIG_PATH try: @@ -395,6 +457,7 @@ async def run_worker(stop_event: asyncio.Event) -> None: last_logged_request_id = "" waiting_log_time = 0.0 expired_heartbeat_request = "" + cleanup_lease = None while not stop_event.is_set(): version = config_version() @@ -434,7 +497,10 @@ async def run_worker(stop_event: asyncio.Event) -> None: await wait_until_timeout_or_config_change(stop_event, 5, version) continue - should_simulate = simulation_is_requested(config) + should_simulate, cleanup_lease = simulation_request_decision( + config, + cleanup_lease, + ) if config["simulationEnabled"] and not should_simulate: if expired_heartbeat_request != config["requestID"]: logger.warning("App heartbeat expired; forcing real GPS restoration") @@ -489,7 +555,10 @@ async def run_worker(stop_event: asyncio.Event) -> None: while not stop_event.is_set(): config = load_config() version = config_version() - should_simulate = simulation_is_requested(config) + should_simulate, cleanup_lease = simulation_request_decision( + config, + cleanup_lease, + ) if not should_simulate: try: @@ -557,7 +626,10 @@ async def run_worker(stop_event: asyncio.Event) -> None: if now - waiting_log_time >= 60: logger.warning("Target iPhone is not connected; waiting") waiting_log_time = now - should_simulate = simulation_is_requested(config) + should_simulate, cleanup_lease = simulation_request_decision( + config, + cleanup_lease, + ) phase = "waitingForDevice" if should_simulate else "clearPending" message = ( "Connect the unlocked iPhone over USB" @@ -584,7 +656,10 @@ async def run_worker(stop_event: asyncio.Event) -> None: raise except Exception as error: logger.exception("Connection failed; retrying") - should_simulate = simulation_is_requested(config) + should_simulate, cleanup_lease = simulation_request_decision( + config, + cleanup_lease, + ) phase = "failed" if should_simulate else "clearPending" write_status( phase, diff --git a/Sources/GeoShift/Resources/ru.lproj/Localizable.strings b/Sources/GeoShift/Resources/ru.lproj/Localizable.strings index c2fc7c8..3de6c5f 100644 --- a/Sources/GeoShift/Resources/ru.lproj/Localizable.strings +++ b/Sources/GeoShift/Resources/ru.lproj/Localizable.strings @@ -99,3 +99,4 @@ "error.previousOperation" = "Предыдущая операция завершилась с ошибкой. Повторите её или откройте диагностику для проверки подключения."; "error.missingDependency" = "Не найден pymobiledevice3"; "error.missingKeeper" = "В приложении отсутствует keeper.py."; +"error.missingLivenessLease" = "GeoShift не может безопасно запуститься: lock живого процесса занят другим экземпляром или операцией очистки. Закройте другой экземпляр и повторите попытку."; diff --git a/Tests/GeoShiftTests/AppLivenessLeaseTests.swift b/Tests/GeoShiftTests/AppLivenessLeaseTests.swift new file mode 100644 index 0000000..abe38b6 --- /dev/null +++ b/Tests/GeoShiftTests/AppLivenessLeaseTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import GeoShift + +@Suite("App liveness lease") +struct AppLivenessLeaseTests { + @Test("The lease remains held until its owner is released") + func exclusiveLifetime() { + let directory = FileManager.default.temporaryDirectory + .appending(path: "GeoShift liveness tests \(UUID().uuidString)") + let url = directory.appending(path: "gui-liveness.lock") + defer { try? FileManager.default.removeItem(at: directory) } + + var lease = AppLivenessLease(url: url) + #expect(lease != nil) + #expect(AppLivenessLease(url: url) == nil) + withExtendedLifetime(lease) {} + + lease = nil + let replacementLease = AppLivenessLease(url: url) + #expect(replacementLease != nil) + withExtendedLifetime(replacementLease) {} + } +} diff --git a/Tests/GeoShiftTests/CommandRunnerTests.swift b/Tests/GeoShiftTests/CommandRunnerTests.swift new file mode 100644 index 0000000..c30ffe2 --- /dev/null +++ b/Tests/GeoShiftTests/CommandRunnerTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import GeoShift + +@Suite("Command runner") +struct CommandRunnerTests { + @Test("A timed-out process cannot strand the next command") + func timeoutDoesNotStrandRunner() async { + let runner = CommandRunner() + let clock = ContinuousClock() + let startedAt = clock.now + + let timedOut = await runner.run( + "/bin/sh", + arguments: ["-c", "trap '' TERM; exec /bin/sleep 30"], + timeoutSeconds: 0.2 + ) + let elapsed = startedAt.duration(to: clock.now) + + #expect(timedOut.status == -2) + #expect(elapsed < .seconds(1)) + + let followUp = await runner.run( + "/usr/bin/true", + arguments: [], + timeoutSeconds: 1 + ) + #expect(followUp.succeeded) + } + + @Test("A launch failure is returned without stranding the runner") + func launchFailureDoesNotStrandRunner() async { + let runner = CommandRunner() + + let failed = await runner.run( + "/GeoShift/does-not-exist", + arguments: [], + timeoutSeconds: 1 + ) + #expect(failed.status == -1) + + let followUp = await runner.run( + "/usr/bin/true", + arguments: [], + timeoutSeconds: 1 + ) + #expect(followUp.succeeded) + } +} diff --git a/Tests/Python/test_keeper.py b/Tests/Python/test_keeper.py index 7400fbc..8ec3d5c 100644 --- a/Tests/Python/test_keeper.py +++ b/Tests/Python/test_keeper.py @@ -1,4 +1,5 @@ import asyncio +import fcntl import importlib.util import json import os @@ -45,7 +46,50 @@ def test_stale_app_heartbeat_turns_simulation_into_restore(self): "appHeartbeatAt": 1_000.0, } - self.assertFalse(keeper.simulation_is_requested(config, now=1_301.0)) + cleanup_lease = object() + should_simulate, returned_lease = keeper.simulation_request_decision( + config, + cleanup_lease=cleanup_lease, + now=1_301.0, + ) + self.assertFalse(should_simulate) + self.assertIs(returned_lease, cleanup_lease) + + def test_stale_app_heartbeat_keeps_simulation_when_gui_is_alive(self): + config = { + "simulationEnabled": True, + "appHeartbeatAt": 1_000.0, + } + + with patch.object( + keeper, + "acquire_app_cleanup_lease", + return_value=("alive", None), + ): + should_simulate, cleanup_lease = keeper.simulation_request_decision( + config, + now=1_301.0, + ) + self.assertTrue(should_simulate) + self.assertIsNone(cleanup_lease) + + def test_liveness_probe_error_never_proves_gui_death(self): + config = { + "simulationEnabled": True, + "appHeartbeatAt": 1_000.0, + } + + with patch.object( + keeper, + "acquire_app_cleanup_lease", + return_value=("unknown", None), + ): + should_simulate, cleanup_lease = keeper.simulation_request_decision( + config, + now=1_301.0, + ) + self.assertTrue(should_simulate) + self.assertIsNone(cleanup_lease) def test_fresh_app_heartbeat_keeps_simulation_requested(self): config = { @@ -53,10 +97,53 @@ def test_fresh_app_heartbeat_keeps_simulation_requested(self): "appHeartbeatAt": 1_000.0, } - self.assertTrue(keeper.simulation_is_requested(config, now=1_299.0)) + should_simulate, cleanup_lease = keeper.simulation_request_decision( + config, + now=1_299.0, + ) + self.assertTrue(should_simulate) + self.assertIsNone(cleanup_lease) + + def test_missing_app_heartbeat_restores_after_gui_death(self): + cleanup_lease = object() + should_simulate, returned_lease = keeper.simulation_request_decision( + {"simulationEnabled": True}, + cleanup_lease=cleanup_lease, + ) + self.assertFalse(should_simulate) + self.assertIs(returned_lease, cleanup_lease) + + def test_liveness_lock_distinguishes_live_and_dead_gui(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "gui-liveness.lock" + descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + state, cleanup_lease = keeper.acquire_app_cleanup_lease(path) + self.assertEqual(state, "alive") + self.assertIsNone(cleanup_lease) + finally: + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + + state, cleanup_lease = keeper.acquire_app_cleanup_lease(path) + self.assertEqual(state, "dead") + self.assertIsNotNone(cleanup_lease) + + competing_descriptor = os.open(path, os.O_RDWR) + try: + with self.assertRaises(BlockingIOError): + fcntl.flock( + competing_descriptor, + fcntl.LOCK_EX | fcntl.LOCK_NB, + ) + finally: + os.close(competing_descriptor) - def test_missing_app_heartbeat_fails_safe_to_restore(self): - self.assertFalse(keeper.simulation_is_requested({"simulationEnabled": True})) + cleanup_lease.close() + replacement_state, replacement_lease = keeper.acquire_app_cleanup_lease(path) + self.assertEqual(replacement_state, "dead") + replacement_lease.close() class WorkerSafetyTests(unittest.IsolatedAsyncioTestCase): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d20218a..19a9bcc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,6 +5,7 @@ GeoShift separates the macOS user interface from the persistent device worker. ```text SwiftUI app ├─ writes atomic config.json + app heartbeat + ├─ holds a kernel liveness lease while the GUI process exists ├─ reads atomic status.json ├─ manages a per-user LaunchAgent └─ runs the iOS 27 pairing assistant @@ -16,7 +17,9 @@ LaunchAgent → bundled keeper.py → pymobiledevice3 → trusted physical iPhon ## SwiftUI controller `KeeperController` owns the selected city, timing settings, state presentation, -pairing flow, and LaunchAgent lifecycle. English is the first-run language; +pairing flow, app-lifetime monitor, and LaunchAgent lifecycle. Monitoring starts +idempotently with the app and refreshes immediately after activation. English is +the first-run language; `LocalizationStore` persists an explicit English/Russian selection without changing the safety request or worker identity. @@ -49,8 +52,13 @@ replaced while simulation may be active. - Normal Start/Restore updates configuration without tearing down a healthy tunnel. - Closing the window or Command-Q queues Restore GPS. - If that handoff cannot be proven safe, termination is refused. -- The app writes a heartbeat every 30 seconds. -- A stale heartbeat older than five minutes converts Start into Restore GPS. +- The app writes a heartbeat every 30 seconds and holds an exclusive advisory + lock for its process lifetime. +- A heartbeat older than five minutes triggers a liveness probe. Start converts + to Restore GPS only if the worker can acquire and retain that lock, proving the + GUI process is gone. An alive, delayed, App-Napped, or suspended GUI keeps Start. +- The worker retains the acquired cleanup lease until the clear result is written + durably, preventing a second GUI from racing an in-progress crash cleanup. - A five-minute watchdog repairs a missing or stale worker. - If the phone is offline, `clearPending` persists until the trusted phone returns. - A corrupt or missing configuration triggers a conservative clear attempt. diff --git a/version.env b/version.env index 0b9f4ce..e5556a8 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=1.5.0 -BUILD_NUMBER=7 +MARKETING_VERSION=1.5.1 +BUILD_NUMBER=8