diff --git a/Sources/KeyPathApp/Info.plist b/Sources/KeyPathApp/Info.plist
index f86e321df..5dbafacfc 100644
--- a/Sources/KeyPathApp/Info.plist
+++ b/Sources/KeyPathApp/Info.plist
@@ -11,7 +11,7 @@
CFBundleDisplayName
KeyPath
CFBundleVersion
- 10
+ 11
CFBundleShortVersionString
1.0.1
CFBundlePackageType
diff --git a/Sources/KeyPathCore/KeyPathHelperContract.swift b/Sources/KeyPathCore/KeyPathHelperContract.swift
index 1a412e62e..336d31613 100644
--- a/Sources/KeyPathCore/KeyPathHelperContract.swift
+++ b/Sources/KeyPathCore/KeyPathHelperContract.swift
@@ -1,5 +1,5 @@
/// Shared identity and compatibility contract for the privileged helper.
public enum KeyPathHelperContract {
/// Version returned by the helper XPC service and packaged in its Info.plist.
- public static let version = "1.3.1"
+ public static let version = "1.3.2"
}
diff --git a/Sources/KeyPathHelper/HelperService.swift b/Sources/KeyPathHelper/HelperService.swift
index c9d17c8ee..8ce613761 100644
--- a/Sources/KeyPathHelper/HelperService.swift
+++ b/Sources/KeyPathHelper/HelperService.swift
@@ -171,6 +171,13 @@ class HelperService: NSObject, HelperProtocol {
executePrivilegedOperation(
name: "stopKanataService",
operation: {
+ // Capture the live process before disabling the service. Once a
+ // launchd job is disabled, `launchctl kill system/` can no
+ // longer resolve the target even though its existing process is
+ // still alive. Signalling the captured PID closes that gap while
+ // keeping the disable-before-signal ordering that prevents a
+ // KeepAlive respawn.
+ let processID = Self.serviceProcessID(Self.kanataServiceID)
// com.keypath.kanata is KeepAlive. Disable it before signaling the
// process so launchd does not immediately respawn it while the CLI
// is waiting for the stopped postcondition.
@@ -184,17 +191,27 @@ class HelperService: NSObject, HelperProtocol {
"Failed to disable KeyPath Kanata service: \(disableResult.out)"
)
}
- let result = Self.run(
- "/bin/launchctl",
- ["kill", "SIGTERM", Self.kanataServiceTarget],
- timeout: 15
- )
- if result.status != 0,
- result.out.localizedCaseInsensitiveContains("No process to signal"),
- !Self.isServiceHealthy(Self.kanataServiceID)
- {
- // The registered KeepAlive job is already stopped or waiting
- // for launchd's throttle window. Stop is idempotently complete.
+ guard let processID else {
+ if !Self.isServiceHealthy(Self.kanataServiceID) {
+ // The registered KeepAlive job is already stopped or
+ // waiting for launchd's throttle window. Stop is
+ // idempotently complete.
+ return
+ }
+ _ = Self.run(
+ "/bin/launchctl",
+ ["enable", Self.kanataServiceTarget],
+ timeout: 15
+ )
+ throw HelperError.operationFailed(
+ "Failed to identify the KeyPath Kanata service process"
+ )
+ }
+ let result = Self.run("/bin/kill", ["-TERM", processID], timeout: 15)
+ if result.status != 0, !Self.isServiceHealthy(Self.kanataServiceID) {
+ // The process exited between inspection and signaling. The
+ // service remains disabled, so the stopped postcondition is
+ // already satisfied.
return
}
guard result.status == 0 else {
@@ -904,6 +921,13 @@ extension HelperService {
return r.status == 0
}
+ static func serviceProcessID(_ serviceID: String) -> String? {
+ let result = run("/bin/launchctl", ["print", "system/\(serviceID)"])
+ guard result.status == 0 else { return nil }
+ return firstMatch(#"\bpid\s*=\s*([0-9]+)"#, in: result.out)
+ ?? firstMatch(#""PID"\s*=\s*([0-9]+)"#, in: result.out)
+ }
+
static func isServiceHealthy(_ serviceID: String) -> Bool {
let r = run("/bin/launchctl", ["print", "system/\(serviceID)"])
guard r.status == 0 else { return false }
diff --git a/Sources/KeyPathHelper/Info.plist b/Sources/KeyPathHelper/Info.plist
index 93399b7f0..a73b233a8 100644
--- a/Sources/KeyPathHelper/Info.plist
+++ b/Sources/KeyPathHelper/Info.plist
@@ -12,10 +12,10 @@
CFBundleShortVersionString
- 1.3.1
+ 1.3.2
CFBundleVersion
- 4
+ 5
CFBundleInfoDictionaryVersion
diff --git a/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift b/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift
index 3863bf967..ef908bf7f 100644
--- a/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift
+++ b/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift
@@ -6,16 +6,19 @@ final class HelperServiceKeepAliveLifecycleLintTests: XCTestCase {
let source = try helperServiceSource()
let stopBody = try functionBody(named: "stopKanataService", in: source)
+ let inspect = try XCTUnwrap(stopBody.range(of: "Self.serviceProcessID(Self.kanataServiceID)"))
let disable = try XCTUnwrap(stopBody.range(of: "[\"disable\", Self.kanataServiceTarget]"))
- let signal = try XCTUnwrap(stopBody.range(of: "[\"kill\", \"SIGTERM\", Self.kanataServiceTarget]"))
+ let signal = try XCTUnwrap(stopBody.range(of: "Self.run(\"/bin/kill\", [\"-TERM\", processID]"))
let restore = try XCTUnwrap(
stopBody.range(
of: "[\"enable\", Self.kanataServiceTarget]",
range: signal.upperBound ..< stopBody.endIndex
)
)
+ XCTAssertLessThan(inspect.lowerBound, disable.lowerBound)
XCTAssertLessThan(disable.lowerBound, signal.lowerBound)
XCTAssertLessThan(signal.lowerBound, restore.lowerBound)
+ XCTAssertFalse(stopBody.contains("[\"kill\", \"SIGTERM\", Self.kanataServiceTarget]"))
}
func testHelperReenablesServiceBeforeStartingOrRestartingIt() throws {
diff --git a/docs/bugs/cli-service-control-helper-bypass.md b/docs/bugs/cli-service-control-helper-bypass.md
index b8d09732a..6fa6ad2f5 100644
--- a/docs/bugs/cli-service-control-helper-bypass.md
+++ b/docs/bugs/cli-service-control-helper-bypass.md
@@ -47,7 +47,13 @@ reported `Could not stop Kanata service`.
The helper now disables `system/com.keypath.kanata` before signaling it. Start and restart
explicitly re-enable the job before kickstart, and an unexpected signal failure restores the
-enabled state. The helper contract advanced to 1.3.1 so installations cannot retain the earlier
-behavior while reporting the helper as fresh. A lifecycle lint test preserves the required
-disable-before-kill and enable-before-kickstart ordering; installed-app acceptance verifies the
-real launchd transition.
+enabled state.
+
+A second installed-app acceptance run exposed a launchd ordering detail: after `launchctl
+disable`, `launchctl kill system/com.keypath.kanata` could no longer resolve the target even
+though its existing process was still alive. The helper now captures the registered service PID
+before disabling the job, then signals that exact PID after the disable succeeds. The helper
+contract advanced to 1.3.2 so installations cannot retain either earlier behavior while
+reporting the helper as fresh. A lifecycle lint test preserves the required
+inspect-before-disable-before-signal and enable-before-kickstart ordering; installed-app
+acceptance verifies the real launchd transition.