diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e640aae..6f8ca46c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Fix host-routed screen observations with Accessibility elements by validating their semantic owner separately from the screen raster target. #715, #710. +- Fix application name and bundle resolution being blocked by reaped processes lingering in LaunchServices; require repeated native absence while retaining refusal for uncertain or changing process identities. #709. ## 4.3.4 - 2026-09-11 diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+Discovery.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+Discovery.swift index 92140f215..6425757fb 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+Discovery.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+Discovery.swift @@ -63,6 +63,11 @@ extension ApplicationService { guard let generation = observation.identity, generation > 0 else { + // LaunchServices can retain a reaped PID. Only two explicit native absence reads + // exclude it; a new generation or any uncertain result still makes inventory partial. + if observation == .absent, identityProvider(processIdentifier) == .absent { + continue + } if observation == .permissionDenied, Self.confirmsNonTargetableMutationProcess( processIdentifier, diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/SystemIdentityResolver.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/SystemIdentityResolver.swift index 78765ef69..64e9121f8 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/SystemIdentityResolver.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/SystemIdentityResolver.swift @@ -46,6 +46,7 @@ public enum SystemIdentityResolver { enum ProcessStartIdentityObservation: Equatable, Sendable { case identity(UInt64) case permissionDenied + case absent case unavailable var identity: UInt64? { @@ -125,8 +126,12 @@ public enum SystemIdentityResolver { errorCode: Int32) -> ProcessStartIdentityObservation { guard bytesRead == Int32(MemoryLayout.stride) else { - // Only a failed full read with explicit EPERM proves the verified permission-denied path. - return bytesRead <= 0 && errorCode == EPERM ? .permissionDenied : .unavailable + guard bytesRead <= 0 else { return .unavailable } + switch errorCode { + case EPERM: return .permissionDenied + case ESRCH: return .absent + default: return .unavailable + } } let seconds = UInt64(info.pbi_start_tvsec) let microseconds = UInt64(info.pbi_start_tvusec) diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ApplicationInventoryTimeoutTests.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ApplicationInventoryTimeoutTests.swift index 3b4d8e7b8..3e61ed7ce 100644 --- a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ApplicationInventoryTimeoutTests.swift +++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ApplicationInventoryTimeoutTests.swift @@ -832,6 +832,79 @@ extension ApplicationInventoryTimeoutTests { } } + @Test + @MainActor + func `reaped native process in LaunchServices inventory does not block a live named target`() async throws { + let child = Process() + child.executableURL = URL(fileURLWithPath: "/usr/bin/true") + try child.run() + child.waitUntilExit() + let deadPID = child.processIdentifier + let existenceResult = kill(deadPID, 0) + let existenceError = errno + #expect(existenceResult == -1 && existenceError == ESRCH) + + let target = Self.mutationApplication(pid: getpid(), name: "Editor", policy: .regular) + let stale = Self.mutationApplication(pid: deadPID, name: "Stale helper") + let nativeReads = AutomationTestLockedValue(0) + let service = Self.mutationService( + applications: [target, stale], + identityProvider: { pid in + guard pid == deadPID else { return .identity(90) } + nativeReads.withValue { $0 += 1 } + return SystemIdentityResolver.processStartIdentityObservation(pid) + }, + eligibilityProvider: { _ in + Issue.record("A missing native process must not use permission-denied eligibility") + return nil + }) + + let inventory = try await service.applicationMutationInventory() + #expect(inventory.isComplete) + #expect(inventory.warnings.isEmpty) + #expect(inventory.items.map(\.processIdentifier) == [target.processIdentifier]) + #expect(nativeReads.value == 2) + + let planner = DesktopTargetPlanning.ApplicationMutationPlanner( + inventoryProvider: { try await service.applicationMutationInventory() }) + for identifier in try [target.name, #require(target.bundleIdentifier)] { + let plan = try await planner.plan(identifier: identifier) + #expect(plan.processIdentity == target.processIdentity) + } + } + + @Test + @MainActor + func `changing or uncertain native absence leaves mutation inventory partial`() async throws { + let transitions: [[SystemIdentityResolver.ProcessStartIdentityObservation]] = [ + [.absent, .identity(91)], + [.absent, .permissionDenied], + [.absent, .unavailable], + [.identity(90), .absent], + [.unavailable, .absent], + ] + let target = Self.mutationApplication(pid: 41201, name: "Editor", policy: .regular) + let helper = Self.mutationApplication(pid: 41202, name: "Helper") + for observations in transitions { + let reads = AutomationTestLockedValue(0) + let service = Self.mutationService( + applications: [target, helper], + identityProvider: { pid in + guard pid == helper.processIdentifier else { return .identity(90) } + return reads.withValue { index in + defer { index += 1 } + return observations[min(index, observations.count - 1)] + } + }, + eligibilityProvider: { _ in nil }) + let inventory = try await service.applicationMutationInventory() + #expect(!inventory.isComplete) + #expect(inventory.items.map(\.processIdentifier) == [target.processIdentifier]) + #expect(inventory.warnings.count == 1) + #expect(reads.value <= 2) + } + } + @Test @MainActor func `legacy optional identity injection never probes eligibility or upgrades nil to denial`() async throws { diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/SystemIdentityResolverTests.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/SystemIdentityResolverTests.swift index 6f0795f07..2be49f172 100644 --- a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/SystemIdentityResolverTests.swift +++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/SystemIdentityResolverTests.swift @@ -15,13 +15,19 @@ struct SystemIdentityResolverTests { info: info, bytesRead: size, errorCode: EPERM) #expect(readable == .identity(123_000_456)) #expect(readable.identity == 123_000_456) + #expect(SystemIdentityResolver.processStartIdentityObservation( + info: info, bytesRead: size, errorCode: ESRCH) == readable) for bytesRead in [Int32(0), -1] { let denied = SystemIdentityResolver.processStartIdentityObservation( info: info, bytesRead: bytesRead, errorCode: EPERM) #expect(denied == .permissionDenied) #expect(denied.identity == nil) - for errorCode in [0, ESRCH, EIO, EACCES] { + let absent = SystemIdentityResolver.processStartIdentityObservation( + info: info, bytesRead: bytesRead, errorCode: ESRCH) + #expect(absent == .absent) + #expect(absent.identity == nil) + for errorCode in [0, EIO, EACCES] { let unavailable = SystemIdentityResolver.processStartIdentityObservation( info: info, bytesRead: bytesRead, errorCode: errorCode) #expect(unavailable == .unavailable) @@ -29,8 +35,10 @@ struct SystemIdentityResolverTests { } } for bytesRead in [size - 1, size + 1] { - #expect(SystemIdentityResolver.processStartIdentityObservation( - info: info, bytesRead: bytesRead, errorCode: EPERM) == .unavailable) + for errorCode in [EPERM, ESRCH] { + #expect(SystemIdentityResolver.processStartIdentityObservation( + info: info, bytesRead: bytesRead, errorCode: errorCode) == .unavailable) + } } info.pbi_start_tvsec = UInt64.max info.pbi_start_tvusec = 999_999 diff --git a/docs/application-resolving.md b/docs/application-resolving.md index b8f6ff2a4..e78062e59 100644 --- a/docs/application-resolving.md +++ b/docs/application-resolving.md @@ -124,6 +124,12 @@ App mutations require an exact name, exact bundle ID, or explicit PID. Name and The native mutation inventory excludes an unreadable helper without marking the inventory partial only when repeated observations agree: activation policy is explicitly prohibited, an exact-sized short-BSD response identifies the expected PID with an effective UID different from the runtime host, and full process-generation reads actually fail with `EPERM`. UID alone never predicts denial, and short BSD never supplies a generation receipt. Unknown policy, other failures, changing evidence, and a readable generation that disappears or changes all remain fail-closed. These checks share the inventory's one-second off-MainActor budget and retained native worker; they do not change read-only discovery or generation-pinned explicit-PID lifecycle targeting. +LaunchServices can also retain records for processes that have exited. Two consecutive failed full-BSD generation +reads with explicit `ESRCH` confirm native absence and exclude that stale row without making mutation inventory +partial. A partial native read, arbitrary failure, or transition between absence and a readable generation remains +an uncertain omission. A process that disappears after its generation was read also remains an omission; retry with +a fresh inventory. Read-only listing and explicit-PID targeting keep their existing behavior. + ### Allowed Redundancy These legacy management forms are valid and equivalent: diff --git a/docs/commands/app.md b/docs/commands/app.md index 7c682f23a..c1503c84a 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -22,7 +22,8 @@ read_when: ## Implementation notes - App mutations accept only an exact case-insensitive application name, exact bundle ID, or explicit `PID:`/`--pid`. Partial-name matching remains available to read-only discovery, but a mutation such as `--app Saf` is refused before any lifecycle action is dispatched. -- Prohibited helpers remain exact-name/bundle candidates whenever their process generation is readable. Only repeatedly confirmed foreign-effective-UID, prohibited rows whose full generation reads fail with `EPERM` are excluded from native mutation inventory without an omission warning. All uncertain omissions still prevent name/bundle uniqueness; read-only listing and explicit-PID lifecycle targeting are unchanged. See [mutation inventory completeness](../application-resolving.md#mutation-inventory-completeness). +- Prohibited helpers remain exact-name/bundle candidates whenever their process generation is readable. Unreadable live helpers are excluded without an omission warning only when repeated observations confirm a foreign effective UID, prohibited activation policy, and full generation reads failing with `EPERM`. All uncertain omissions still prevent name/bundle uniqueness; read-only listing and explicit-PID lifecycle targeting are unchanged. See [mutation inventory completeness](../application-resolving.md#mutation-inventory-completeness). +- Stale LaunchServices rows for exited processes are excluded only after two native generation reads return `ESRCH`. A changing generation, partial read, or other error still prevents name/bundle uniqueness. - Launch resolves explicit paths, bundle IDs, PID selectors, and friendly names on the selected runtime host. Without `--foreground`, it may only return an exact already-running app as a verified no-op; it may resolve the application URL but never dispatches a LaunchServices open/start. Cold launch, `--open`, `--new-instance`, and relaunch refuse before dispatch because macOS does not provide a trustworthy nonactivation guarantee. These refusals report `INTERACTION_FAILED`, `effect: refused`, `retry_safe: true`, and `mutation_dispatched: false`, with explicit foreground guidance. Background launch also requires a host that advertises this exact no-op contract, so a rolling upgrade cannot delegate to an older host that would cold-launch. The deprecated `--no-focus` flag remains a no-op compatibility alias. - Background no-op `launch --wait-ready` and `--wait-for-window` retain the selected PID/process-generation receipt throughout their read-only waits. A readiness failure remains explicitly retry-safe with `mutation_dispatched: false`. A `PID:` selector stays pinned to that exact process generation for both the no-op and a plain foreground activation; it cannot be combined with `--open` or `--new-instance`. Foreground launch keeps the full existing LaunchServices behavior for path/name/bundle selectors: it can start windowless/accessory apps, deliver documents/URLs, create a distinct process, and wait up to 10 seconds for a real WindowServer window. `relaunch` retains its single `--wait-until-ready` spelling and requires `--foreground` before the target is resolved or quit. - JSON launch output returns the launch-bound numeric compatibility field `process_start_identity` plus the lossless authoritative string `process_start_identity_decimal` beside `pid`, along with refreshed `window_count`, `window_ready`, and `window_ids`. Relaunch uses `new_process_start_identity` and authoritative `new_process_start_identity_decimal`. JSON-number consumers must not use the numeric forms for exact comparison because values above 2^53 can lose precision. A current native host captures that process generation from the exact process selected by LaunchServices and refuses the result if the PID is recycled before return. Older runtime hosts may omit the process identity for foreground launch, but background launch fails closed unless the host advertises the safe no-op contract; cleanup callers must never probe a returned PID to manufacture a new receipt. `window_identity` is `exact` when the window IDs came from WindowServer and `unknown` for an older runtime host that cannot provide that metadata.