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 @@
-
+
@@ -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