Skip to content

Fix two service-lifecycle defects found during the physical-HID proof - #1300

Merged
malpern merged 5 commits into
masterfrom
fix-restart-registration
Sep 15, 2026
Merged

malpern merged 5 commits into
masterfrom
fix-restart-registration

Conversation

@malpern

@malpern malpern commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Two defects surfaced while running the P02 physical-HID proof on a disposable
macOS 26 guest. Both leave the user believing something worked when it did not.

A failed restart left the daemon unregistered

keypath service restart could leave com.keypath.kanata absent from the
launchd system domain entirely, with no command-line path back. On the guest,
launchctl then reported no such service and keypath system repair returned
userActionRequired; only the app UI recovered it.

Cause: restart() is stop() then start(). Stop removes the SMAppService
registration before it verifies its postcondition, and start swallows a failed
re-register with try?. Either path can throw with the registration already
gone, and nothing rolls it back.

Restart now restores the registration before surfacing the failure, so a restart
that cannot succeed leaves the caller no worse off than before it ran. A
registration merely awaiting approval is left alone, since re-registering would
discard the pending approval.

Start also polls briefly while a fresh registration settles. SMAppService
registers asynchronously, so the status read taken immediately after register()
can still report notRegistered and fail a start that would have worked a moment
later. That is the likely cause of recovery needing two attempts after an
emergency stop, which is what we observed.

Also corrects two messages that actively misdirected this investigation: the
restart hint blamed administrator authorization without mentioning the service
state, and the runtime wizard said "Click Fix" while its buttons read Start and
Restart.

A rule for a collection-owned key was persisted, then refused

With the built-in Home Row Arrows collection enabled, which claims f as a
momentary layer activator:

keypath rule add f --tap f --hold lsft --on-conflict replace --apply

reported the rule as created, and the apply that followed then refused. The rule
stayed in CustomRules.json and broke every later keypath config apply until
removed by hand. --on-conflict replace could not help, because addRule
compared the new rule only against other custom rules and never against
collections.

The conflict detector itself works correctly and fires as intended (#667); it
simply fires far too late, after the write. The facade now pre-flights the
prospective rule set through that same detector before anything is written, and
rule add renders the refusal as an exit-4 conflict naming the owning collection.
Conflicts already present between collections are subtracted, so a store in a bad
state does not block an unrelated key. The app-facing initializers supply no
collection loader, so GUI behavior is unchanged.

Verification

25 tests across the two areas, including 3 new daemon regression tests covering
rollback after a failed stop, rollback after a failed register, and the settling
poll; and 9 new tests covering the conflict refusal, the skip strategy, and the
cases that must still succeed. Adjacent rule, config-generation, deduplication,
and lifecycle suites all pass.

Note: the repo's pinned test scripts could not run here, because the bundle at
Xcode-26.6.app currently contains Xcode 27 and the pin guard rejects it. Tests
were run with swift test directly. That drift is worth a separate look.

🤖 Generated with Claude Code

malpern and others added 2 commits September 15, 2026 08:30
A failed `keypath service restart` could leave com.keypath.kanata absent
from the launchd system domain entirely, with no command-line path back:
`stop()` removes the SMAppService registration before it verifies its
postcondition, and `start()` swallows a failed re-register. Proven on a
disposable macOS 26 guest, where launchctl then reported no such service
and `system repair` needed user action to recover.

Restart now restores the registration before surfacing the failure, so a
restart that cannot succeed leaves the caller no worse off than before it
ran. A registration merely awaiting approval is left alone, since
re-registering would discard the pending approval.

Starting also polls briefly while a fresh registration settles. SMAppService
registers asynchronously, so the status read taken immediately after
register() can still report notRegistered and fail a start that would have
worked moments later. That is the likely cause of recovery needing two
attempts after an emergency stop.

Also corrects two user-facing messages that sent this investigation down the
wrong path: the restart failure hint blamed administrator authorization
without mentioning the service state, and the runtime wizard said "Click
Fix" while its buttons read Start and Restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User-visible bug: with the built-in "Home Row Arrows" collection enabled
(it claims `f` on the base layer as a momentary layer activator), running

    keypath-cli rule add f --tap f --hold lsft --on-conflict replace --apply

reported the rule as created. The rule could never take effect: config
generation keeps the collection's `layer_home-arrows_f` binding and drops
the custom `beh_base_f` one, and `ConfigurationService.generateConfiguration`
refuses to generate at all once `RuleCollectionDeduplicator.detectConflicts`
sees the collision (the activator-vs-mapping branch, #667).

Reproduced both halves in tests. The detector does fire, so the conflict is
not invisible — but it fires far too late. `RulesFacade.addRule` compared the
new rule only against other custom rules in CustomRulesStore, never against
collections, so it persisted the rule and reported success; the apply that
followed then failed, and the rule stayed in the store poisoning every later
`keypath config apply` until it was removed by hand. `--on-conflict replace`
could not help, because it cannot see a collection-owned key at all.

Fix, matching this codebase's detect-and-explain lineage (#458, #462, #463,
#464, #466, #667) and the GUI's interactive resolution (#460) rather than
silently letting one side win: the CLI-operation `RulesFacade` now loads
RuleCollections.json and pre-flights the prospective rule through the same
`detectConflicts` the generator uses, before anything is written. Conflicts
that already existed between collections are subtracted, so a store that is
already in a bad state does not block an unrelated key. `--on-conflict=skip`
no-ops; every other strategy raises `CLICollectionConflictError`, which
`rule add` renders as an exit-4 conflict naming the owning collection and
telling the user to disable it or pick another key. Nothing is persisted.

The app-facing `RulesFacade()` and `RulesFacade(store:)` initialisers supply
no collection loader, so their behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Reviewed the diff. One correctness concern worth a look, plus a minor consistency note.

RulesFacade.collectionConflicts dedupes "new vs. pre-existing" conflicts by string-matching .description (Sources/KeyPathAppKit/CLI/RulesFacade.swift):

let before = Set(... .map(\.description))
return after.filter { !before.contains($0.description) }

This works only if description/userExplanation is a faithful, stable proxy for conflict identity. If MappingConflictInfo isn't Hashable/Equatable (which the reliance on .description here suggests), consider comparing on a structured identity instead (e.g. inputKey + the set of conflictingCollections) rather than free-text. As written, a future wording tweak to the description, or two logically distinct conflicts that happen to render identical text, would silently break the "only block genuinely new conflicts" guarantee this function exists for — which is exactly the class of bug the accompanying tests (testPreExistingCollectionConflictDoesNotBlockAnUnrelatedRule) are trying to prevent.

Minor: WizardKanataServicePage.swift changes the hint copy per-state ("Click Start" for .stopped/.unknown, "Click Restart" for .failed), but the diff doesn't show the corresponding button label logic. Worth a quick visual check that the actual buttons in each state say "Start"/"Restart" rather than "Fix", so the copy and the control match.

Everything else — the KanataDaemonService registration-settle polling, the restart-rollback-on-failure logic, and the new collection-conflict tests — looks correct and is well covered by the added tests.

@malpern

malpern commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

CI note: build-and-test failed at the Verify runner disk reserve step with exit 75, before compiling anything.

disk_reserve_free_gib	66
disk_reserve_minimum_gib	90
disk_reserve_busy: free space is below the 90 GiB safety reserve

Exit 75 is this project's infrastructure-busy signal, so this is a wait on runner disk rather than a rejection of the change. claude-review and code-quality both pass.

The runner's own actions-runner-cleanup.sh was run and freed nothing: CI scratch already lives on the external volume, which has about 3 TB free. The shortage is on the mini's startup volume, from data unrelated to CI. Re-run once that has headroom.

Xcode 26.6 was upgraded in place on the build machine, leaving a bundle
still named Xcode-26.6.app that actually contains Xcode 27.0 (27A266a).
The pin guard compares the reported version, so it correctly refused the
mismatch — and every canonical build, test, deploy, and release script
stopped working, CI included. Nothing had built since, because the only
runs in between were docs-only or a ping job.

Advance the pin rather than reinstall the old toolchain: 26.6 no longer
exists on the machine, and the bundle now holds the release build of 27.
The bundle is renamed to Xcode-27.app so its name matches its contents;
the misleading name is what made this look like a missing install.

Verified per the documented procedure: ensure-metal-toolchain.sh passes
with no override, DeploymentScriptContractTests passes through
run-tests-safe.sh, and both suites from this branch run clean through the
canonical runner rather than a bare swift test.

Note for anyone bisecting: Xcode 27's Metal toolchain reports unavailable
on the same invocation that downloads it, and resolves once the asset
cryptex mounts. Re-run the script rather than concluding it failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

One behavioral inconsistency worth a look before merge:

RulesFacade.addRuleonConflict: .skip isn't honored on the merge path (Sources/KeyPathAppKit/CLI/RulesFacade.swift, ~L160-172)

The new-rule path correctly special-cases skip:

let conflicts = await collectionConflicts(adding: rules, baseline: baseline)
if !conflicts.isEmpty {
    if onConflict == .skip { return .skipped }
    throw CLICollectionConflictError(...)
}

But the merge path (updating an existing custom rule for the same input) always throws on a collection conflict, with no onConflict == .skip check:

rules[existingIndex] = merged
let conflicts = await collectionConflicts(adding: rules, baseline: baseline)
if !conflicts.isEmpty {
    throw CLICollectionConflictError(input: input, conflicts: conflicts, ruleName: merged.displayTitle)
}

So keypath rule add <key> ... --on-conflict skip succeeds silently (returns .skipped) when the new rule collides with an enabled collection, but throws (nonzero exit) when the identical collision arises from merging into an existing rule for the same input — same flag, inconsistent CLI behavior depending on whether the input key already had a custom rule. If the asymmetry is intentional (e.g. merge conflicts should always be hard errors because the user is actively changing an existing rule), a short comment explaining that would help; otherwise this should respect onConflict the same way the append path does.

Minor/non-blocking: CLICollectionConflictError.collectionNames filters out any conflicting collection whose name exactly equals ruleName (the rejected rule's own display title), to avoid the message citing the rule against itself. If a real enabled collection happens to share that exact display name, it would be silently dropped from the reported collectionNames/description. Very low probability, just flagging.

No other correctness/reliability issues found in the diff — the KanataDaemonService restart-rollback and registration-settle-polling logic looks sound and is covered by the new integration tests, and the collection-conflict detection itself (collectionConflicts) is well-reasoned (subtracts pre-existing collection-vs-collection conflicts so an already-conflicted store doesn't block unrelated rules).

Advancing the pin exposed 40 warnings that Xcode 26.6 accepted silently.
The build and test guardrails allow none, so nothing could merge until
these were addressed. Neither group was introduced by this branch.

Sendable conformance placement (24 warnings, all in one file). Six classes
conform to wizard protocols that inherit Sendable, declared in
WizardProtocolConformances.swift rather than beside each class. Swift 6
requires the conformance to live with its type, so each class now states it
in its own file. The conformance was already in force through the protocol,
so this records an existing guarantee where the compiler wants it rather
than making a new claim about thread safety. A test subclass has to restate
the inherited conformance, so it does.

Environment access outside a View (16 warnings, two call sites). The wizard
dismissal path read @Environment(\.dismiss), and the overlay keycap read
@Environment(\.services), from tests that construct those views without
installing them in a hierarchy. SwiftUI warns on every such read. Both views
now accept an injected value that tests supply, and fall back to the
environment in the app, where it is real. The overlay test even documented
its reliance on the environment default; that reliance is now explicit
rather than incidental.

Verified with the canonical runner across all affected suites: 106 tests
pass with zero build-log and test-log warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Automated review of this diff — a few points worth a second look:

  1. Six @unchecked Sendable retrofits without a thread-safety audit (KanataDaemonManager, RuntimeCoordinator, UninstallCoordinator, HelperMaintenance, PermissionRequestService, SystemValidator). The comment argues these conformances were "already in force" via the wizard protocol, but @unchecked Sendable disables the compiler's actual data-race checking — it doesn't just restate an existing guarantee, it removes future verification for these types. If any of these classes hold mutable, non-isolated state, this silently reintroduces the race conditions Swift 6 strict concurrency is meant to catch. Worth a per-type note (or a quick audit) confirming each is genuinely safe (e.g., actor-isolated internals, immutable state, or existing locking) rather than blanket-suppressing the checker.

  2. Unrelated Xcode pin bump (Scripts/lib/xcode.sh: 26.627.0, plus the matching DeploymentScriptContractTests update) is bundled into a PR whose stated purpose is rule-collection conflict detection and daemon restart recovery. AGENTS.md asks for minimal, focused diffs — if this bump isn't required by the rest of the change, consider splitting it into its own PR so it can be reviewed/rolled back independently (toolchain pin changes have a different risk profile than the app logic changes here).

  3. restoreRegistrationAfterFailedRestart doesn't use the new settle-polling that start() now uses via waitForRegistrationToSettle(). The PR's own rationale is that a status read taken immediately after a registration change can be stale (.notRegistered/.notFound when it's actually about to settle). restoreRegistrationAfterFailedRestart reads currentRegistrationStatus() once, immediately after a failed stop()/start(), and skips restoring if that single read isn't .notRegistered/.notFound. Given the stated async-settling behavior, this could under-fire (skip restoring a registration that's genuinely gone but hasn't reported it yet) — consider reusing waitForRegistrationToSettle() here too, or explain why the immediate read is safe in this path but not in start().

No blocking correctness issues found in the RulesFacade collection-conflict logic or the new test coverage — that part looks solid (before/after diffing to avoid over-firing on pre-existing conflicts is a nice touch).

Two loose ends from the Xcode 27 warning cleanup.

The wizard status overview read @State from a test that never installs it in
a view hierarchy, which SwiftUI warns about and which hands back a fresh
instance on every read. It takes the same injected-value treatment as the
other two views: tests supply the value, the app still uses real state.

The formatter also has to be satisfied on every file this branch touches,
not only on the lines it changed. An earlier attempt reverted that
whole-file formatting to keep the diff tight, which was the wrong call: the
code-quality gate lints entire changed files, so the reformatting is
required rather than incidental churn. The Sendable notes are doc comments
now, which is what the docComments rule wants ahead of a declaration.

129 tests pass across every affected suite with zero warnings, and
swiftformat reports nothing left to format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Reviewed the diff (note: the tool truncated the tail of KanataDaemonServiceIntegrationTests.swift, but the reviewed portion covers the substantive product changes).

Findings:

  1. Unrelated infra change bundled in (Scripts/lib/xcode.sh + DeploymentScriptContractTests): bumping the default stable Xcode pin from 26.6 → 27.0 has nothing to do with the rule-collision/registration-recovery work in this PR. AGENTS.md requires this to be an intentional, verified pin change (install + verify Xcode 27, then run the contract test plus a canonical build/deploy script without an override) before landing. Please confirm that verification happened, or split this into its own PR so it gets that scrutiny independently.

  2. CLICollectionConflictError.collectionNames filters by string equality on ruleName/displayTitle (RulesFacade.swift). The exclusion of "the pending rule's own collection name" from the reported conflict list relies on asRuleCollections() naming the synthetic collection exactly the same string as rule.displayTitle. If those two ever diverge (e.g., two custom rules sharing a displayTitle, or future changes to how asRuleCollections() names entries), the error could mistakenly tell the user to "disable" a collection that's actually their own not-yet-saved rule. Filtering by identity (rule id/input) rather than display-name string match would be more robust. Not currently broken per the added tests, but fragile.

  3. Mass-applied @unchecked Sendable across HelperMaintenance, KanataDaemonManager, RuntimeCoordinator, UninstallCoordinator, PermissionRequestService, SystemValidator. The comment asserts these were "already Sendable in practice" via a protocol conformance, but @unchecked Sendable fully disables compiler verification for each of these mutable classes. Worth an explicit confirmation (or a code comment pointing at the actual synchronization mechanism, e.g. actor isolation/locks) that each type is genuinely safe to share across threads, rather than relying on "the protocol required it so it must have been fine."

Looks solid:

  • RulesFacade collection-conflict check (compares only new conflicts vs. baseline, so a store that's already inconsistent doesn't block unrelated rules) is a good design, and it's well covered by CLIRuleCollectionConflictTests.swift.
  • KanataDaemonService.restart()'s failure-path re-registration (restoreRegistrationAfterFailedRestart) correctly avoids clobbering a pending-approval state, only acting when the registration is fully gone.
  • waitForRegistrationToSettle polling is bounded (6 × 250ms) and only triggers on the narrow .notRegistered/.notFound transient case.

@malpern
malpern merged commit aefb330 into master Sep 15, 2026
3 checks passed
@malpern
malpern deleted the fix-restart-registration branch September 15, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant