Repository navigation
Stop orphaned session workers and verify none survive uninstall - #1361
Conversation
On 2026-11-03 an uninstall on the Air reported success while two --session-capabilities probes kept running from the trashed bundle, with an owner PID that no longer existed. Workers: only the runtime's 5 ms tick checked --session-owner, and the tick exists only once start() has finished. Capability probes never checked it, so a probe that hung outlived its owner indefinitely. SessionOwnerWatch now polls the owner on a background queue in both modes; a probe exits with _exit when its owner dies or after 15s, and a runtime worker hands off to an orderly finish on its run loop once it may hold keys or a Caps mapping. Parent: the probe was signalled only after the 2s wait loop ran out. A cancelled wait threw past the SIGTERM. Every abandoned probe is now signalled. Uninstall: prepare stops only the worker the live app tracks. SessionWorkerSweep lists this UID's KeyPath session workers from the kernel process table (any bundle, including the Trash), SIGTERMs them, kills probes that ignore it, and never kills a runtime worker. The coordinator sweeps before removing anything and again before reporting success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An unreadable process table, or a KeyPath process whose arguments cannot be read, now makes the sweep unverified rather than empty. The table read retries on ENOMEM. - Only processes whose p_comm is KeyPath pay for an argument read, with one shared buffer; graces are measured by wall clock. - Workers that appear mid-sweep are signalled, and any probe still present after the probe grace is killed. - Uninstall stops workers of the bundle being removed and orphans of any bundle, so a separate install's live worker is left alone. - An orphaned runtime worker whose run loop never runs its retirement exits after 30s instead of living forever. - The probe wait is extracted as awaitCapabilityReport, with tests for the cancelled-wait regression. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…exit - An orphan's owner counts as alive only if that PID is a KeyPath app running the worker's own executable. A reused owner PID no longer hides an orphan from uninstall. - Uninstall distinguishes a worker that did not stop from a process table it could not read, with its own message for each, and logs every sweep including whether it was verified. - A process that exits or becomes a zombie mid-read is skipped, and one unreadable listing is retried before the sweep is unverified. - The 30s owner-exit hard exit is a cancellable work item: an orderly shutdown cancels it when it begins, and nothing is armed if one is already under way. - The final sweep's failure path documents why it does not resume. - Launch arguments are built in one place, and a test pins them to the sweep's parser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The sibling-bundle test now fails if the trailing "/" in the bundle prefix check is dropped: its owner is live and matching, so only the boundary rule can exclude it. - stopAll returns unverified on cancellation instead of re-reading the process table at full speed for the whole grace. - The process-table read never claims more bytes than its buffer holds. - A runtime worker's owner backstop polls every 2s with leeway; its 5 ms tick already covers the live case. - SessionShutdownFallback.arm documents what its result means. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Review (diff truncated at 60KB; the tail of the test files was not seen) Overall the design is sound (owner watch, shutdown fallback, uninstall sweep with pre/post checks). Findings:
No blocking correctness bugs found in the visible portion. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 854853f28f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| lock.lock() | ||
| let current = self.handler | ||
| lock.unlock() | ||
| current(verdict) |
There was a problem hiding this comment.
Synchronize handler replacement with verdict delivery
If the owner exits during runtime startup, the timer can copy the initial _exit(0) handler, release the lock, and be descheduled before invoking it. Meanwhile, runIfRequested() can complete setHandler() and enter worker.start(), which may acquire a Caps mapping. When the timer resumes, it still invokes the old hard-exit handler, bypassing finish() and leaving the mapping unrestored. Make the transition to orderly shutdown synchronize with in-flight handler delivery, rather than protecting only the handler property.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Verdicts are now delivered while holding the lock, and setHandler waits for one in flight, so the startup hard-exit handler can't run after the orderly one is installed. Covered by testSetHandlerWaitsForAVerdictInFlight.
🤖 Addressed by Claude Code
| alive.update { $0 = false } | ||
| wait(for: [fired], timeout: 2) | ||
| // A cancelled timer never delivers a second verdict. | ||
| Thread.sleep(forTimeInterval: 0.1) |
There was a problem hiding this comment.
Replace the test's real sleep with controlled time
The new owner-watch test uses Thread.sleep to wait for possible duplicate callbacks, contrary to the explicit AGENTS.md testing rule: “Do not use Thread.sleep. Use Date overrides or mock clocks.” This adds a wall-clock delay while still making the negative assertion depend on background-queue scheduling. Inject a controllable scheduling seam and advance or drain it deterministically before checking the callback count.
AGENTS.md reference: AGENTS.md:L380-L381
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Removed the Thread.sleep: the test injects a serial queue and drains it with queue.sync {} before checking the callback count. The no-sleep lint now passes.
🤖 Addressed by Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review (diff was truncated at 60 KB, so the last uncovered test file was not reviewed). No blocking correctness issues found. The owner-watch, shutdown-fallback and sweep logic reads consistently. Notes:
Test coverage is good: the argv parser is pinned to the real launch arguments, and both cancellation and unverified sweep paths are tested. |
The watch copied its handler, released the lock and then called it, so an owner exit during startup could run the hard-exit handler after setHandler had installed the orderly one, skipping Caps restoration. It now delivers under the lock, and setHandler waits for a verdict in flight. The duplicate-callback test drains the watch's own serial queue instead of sleeping, which the no-sleep lint rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review of PR #1361. The diff was truncated at 60000 bytes, so I did not see the end of No blocking issues found. The owner-watch, shutdown-fallback and worker-sweep logic holds up. The cancellation, unreadable-table and runtime-never-SIGKILL paths are handled and tested. Minor notes, none blocking:
|
Test runners may rewrite argv, so comparing it with ProcessInfo.arguments could flake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review of PR #1361. The supplied diff was truncated at 60 KB, so the tail of I found no blocking correctness issues in what I could see. The owner-watch handler swap, the shutdown-fallback cancellation, the sweep's SIGTERM-only handling for runtime workers, and the unverified-on-cancel path all look consistent. I have two minor notes:
The new tests cover the main paths: the cancelled-wait signal, the timeout, handler-swap ordering, the sweep's kill and grace behavior, and the unverified results. |
The kernel reports a worker's resolved executable path, while the bundle URL may reach it through a symlink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review of PR 1361 (diff truncated at 60KB; the tail of SessionUninstallCoordinatorTests was not reviewed). Findings
No blocking correctness bugs found in the supplied portion. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review (diff truncated at 60KB; the tail of SessionUninstallCoordinatorTests was not reviewed). Findings:
No blocking correctness issues found in the reviewed portion. |
|
Review of PR #1361 (the diff was truncated at 60 KB, so the tail of I found no blocking correctness issues. Both the owner-watch hard exit and the uninstall sweep look sound. Specifics I checked:
Minor points, none blocking:
Test coverage for the new paths (watch verdicts, handler swap, sweep scope, PID reuse, malformed argv) looks good. |
|
Review (diff truncated at 60KB; later test file portion not reviewed) Overall the change looks solid. A few actionable notes:
No blocking issues found. |
Conflict in ServiceLifecycleCoordinator+SessionRuntime.swift: both sides added independent declarations at the same spot (legacyDaemonRefusalMessage here; sessionLaunchArguments and awaitCapabilityReport from #1361). Kept both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
On 2026-11-03,
keypath-cli uninstall --yes --reset-permissions(1.0.2, MacBook Air) reported success and trashed the app while two session workers (PIDs 13284, 29722) kept running from the trashed bundle. Both were--session-capabilitiesprobes whose--session-owner(30378) no longer existed, and both had to be killed by hand.Three causes:
--session-owner, and that tick exists only afterstart()completes. A capability probe that hung lived forever.Task.sleepthrew past theSIGTERM.Fix
Worker side
SessionOwnerWatchpolls the owner on a background queue in both modes. A probe calls_exit(0)when its owner dies or after 15 s. A runtime worker hands off to an orderlyfinishon its run loop once it may hold keys or a Caps mapping.SessionShutdownFallbackbounds the case where that run loop never runs:_exit(1)after 30 s. It is cancelled as soon as an orderly shutdown begins, so it never cuts off a Caps restore.Parent side
awaitCapabilityReport(extracted from the probe wait) signals the probe on every abandoned path: timeout, cancellation, error.sessionLaunchArgumentsbuilds the worker argv in one place, and a test pins it to the sweep's parser.Uninstall
SessionWorkerSweepreads this UID's process table viasysctl(nopgrep), with ap_commprefilter and one shared argument buffer. It covers workers of the bundle being removed and orphans of any bundle.unverified, never "none running".SessionUninstallCoordinatorsweeps afterprepare(on failure nothing is removed, and it resumes) and again before reporting success, per the AGENTS.md postcondition rule. "Still running" and "could not check" get separate messages, and every sweep is logged.Tests
50 tests pass. They use injected seams only. The one live test reads this test process's own table entry and argv, and signals nothing.
Review gate:
./Scripts/review-gate.shpassed on854853f2, after three rounds of fixes (each confirmed finding addressed in its own commit).Not verified
Follow-ups (from the final review, all PLAUSIBLE)
acquireTerminationGuard; the sweep could reuse that.stopAlldoes not re-read the table after its last signal round, so a worker exiting at the grace boundary is reported as remaining. This errs toward refusing.setActivationPolicymain-actor hop; it could be armed before it.SessionWorkerSweep.livepropagating nil, and the worker-sidefinish→shutdownBegins()wiring.🤖 Generated with Claude Code