Skip to content

fix(native): keep UI fixture ownership through a missing handshake - #8646

Closed
kaoru0822-kitauji wants to merge 9 commits into
mainfrom
fix/8559-native-ui-fixture-ownership
Closed

kaoru0822-kitauji wants to merge 9 commits into
mainfrom
fix/8559-native-ui-fixture-ownership

Conversation

@kaoru0822-kitauji

@kaoru0822-kitauji kaoru0822-kitauji commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What broke: verify-native-ui.mjs learned its fixture's PID only from the ready record, so a missing handshake stopped open, deleted the fixture root and left the app running, reparented to PID 1. Reproduced on latest main with real LaunchServices.
The fix: A new owner holds the run token from before launch, stops the launcher by its handle, retires the app path, sweeps by token, and removes the root only after that succeeds. A refused cleanup keeps the root and run for retry.
The risk: Limited to this macOS verifier and its CI scope. The fixture now ends by SIGKILL from the sweep instead of SIGTERM, repeated Ctrl-C no longer force-quits during release, and positive capture and actions could not be run here because Screen Recording is not granted.

Summary

Fixes #8559.

The verifier could orphan its fixture app whenever the ready handshake was late, missing or invalid, because nothing identified the app until the record arrived. The verifier now owns the launch from before it starts, so cleanup does not depend on the handshake, and it does not delete the root unless cleanup is proven.

Problem and expected behavior

Smallest trigger: a fixture that starts and never writes its ready record. open is signaled and exits, and the app stays alive with no recorded identity. The violated invariant is that the root must outlive any owned process. The cleanup must also not signal on a PID alone, yet a ready record could name any PID, and that PID was then signaled.

Change and scope

  • scripts/lib/native-ui-fixture-lifecycle.mjs owns the root, launcher and fixture. It reuses spawnOwnedProviderProcess, ProviderProcessSupervisor, cleanupOwnedProcessGroup and observeProcessStartIdentity; it adds no ownership primitive.
  • Release order is: stop the launcher through its retained child handle, retire the app path by rename, token-sweep, remove the root. Any failure throws NativeUiFixtureCleanupFailure, which names the root and run and keeps the owner retryable. A clean sweep proves nothing while a launch can still start, so every attempt sweeps again, and only an attempt whose steps all succeeded removes the root.
  • /usr/bin/open is a platform binary whose token macOS withholds, so the sweep cannot prove it. That is why the handle stops it first.
  • The ready PID must carry this run's token before it is used. A launcher that exits before the handshake now fails at once with its exit status and stderr instead of waiting out the 20 seconds.
  • Both Swift compiles run through the owner, on the existing execFileOutput path with SWIFTC_INTERRUPT. Release stops them as swiftc requires and joins them before it touches the root.
  • runNativeUiFixtureLane wraps the verifier's operation and settles the process outcome in one place, through withVerifierCleanup, so primary and cleanup failures both surface.
  • The token travels through the inherited environment documented in open(1), not --env. My first version used --env and it made unrelated sweeps fail: the shared reader treats an argv entry with the token prefix as ambiguous_environment_boundary, so a readable launcher holding one failed verifierTerminalOwner tests running beside it.
  • CI: the new test joins the macOS apple-artifacts job and scope, because the entrypoint tests can only run on macOS.

Added beyond the literal issue, with the failure mode each one addresses:

  • App path retirement (one rename). Measured, not speculative. Killing real open 10 or 20 ms after spawn, then sweeping immediately, the app appeared after the sweep in 6 of 6 rounds. With the rename, the app did not launch in 15 of 15 rounds at +0 to +30 ms, and at +40 ms the immediate sweep saw it in 3 of 3. Likelihood is low in the normal 20-second path and higher for an early interrupt or a stalled launch. No new on-screen window owner, such as a LaunchServices alert, appeared during the probe.
  • Signal handling (about 50 lines, including the status rule). The owned launcher is now detached, so Ctrl-C no longer reaches it, and without handling my change would leave a stray open as well as the fixture. A signal only records the interruption and ends the operation's race. The release always runs to settlement, and every signal stays handled until then, because npm forwarding Ctrl-C can deliver the same signal twice. SIGKILL remains the override.

Exit status when a signal is involved (the first failure selects the status, and every failure is reported):

When the signal arrives Release Exit Output
During the operation Succeeds 130 or 143 Interruption message
During the operation Fails 130 or 143 Retained root and run
During release, after the operation failed Succeeds 1 Primary failure
During release, after the operation failed Fails 1 Primary failure, retained root and run
During release, after the operation completed Succeeds 130 or 143 Completed report on stdout
During release, after the operation completed Fails 130 or 143 Retained root and run; no report

Not included: the sibling verifier issues, and the two unrelated lane failures listed under proof gaps.

Contribution scope: ordinary fix within existing boundaries; maintainer scope decision not applicable.

Contract and boundary impact

  • Semantic owner and earliest changed stage: the new fixture owner, from before launch. The verifier's inline launch, handshake, PID identity and rm are removed.
  • Contribution scope fit and linked maintainer decision: not applicable.
  • CLI and MCP/tool-catalog contract: none. The verifier's success report is unchanged.
  • Provider, bridge, target-format, or platform compatibility: macOS verifier only. Requires a host that passes the launcher's environment to the app, as open(1) documents; if that stops, the ready check fails loudly.
  • Evidence, artifact, provenance, or reconstruction contract: none.
  • Target selection, authority, upstream behavior, and cleanup details: only the source-owned fixture is launched. Signals go to the launcher's retained handle and to groups whose every member proves the run token. A group with an unproven member is refused and not signaled.
  • Generated metadata, package, or installation impact: none.

Expected effects (check known effects; use documentation-only on its own).

  • Read existing files or captured data.
  • Launch or attach to a process.
  • Inject instrumentation or run scripts/code.
  • Change process memory, registers, files, or configuration.
  • Contact network services.
  • Recover protected code or work around protections.
  • Documentation or usability only; no target interaction.
  • Not sure yet; investigation or maintainer guidance needed.

The launched process is the verifier's own fixture, as before.

Evidence and regression coverage

  • Tests added or updated: tests/boundary/process/nativeUiVerifierFixtureOwnership.test.ts (38 tests), plus three rows in ciChangeScope.test.ts. The launcher and fixture are authored stand-ins; the owner, spawn, token sweep, signals and root removal are real. 28 tests run on POSIX hosts, and 10 drive the actual verifier entrypoint on macOS only. Final-cleanup tests park the real release at a barrier inside its retire step, then deliver the signals. The in-flight launch test holds the real release just before root removal, so its ordering does not depend on scheduling.
  • Base reproduction (executed on macOS 26.7.1 arm64, Node 24.22.1, 8250bf232): the actual verifier with only the compiler injected, real /usr/bin/open and LaunchServices, and a never-ready AppKit binary. It exited 1 after 20 seconds with the root deleted, and the fixture was still alive:
    36941     1 36941 .../rea-ui-fixture-04TTpR/Fixture.app/Contents/MacOS/fixture
    
    This settles the LaunchServices question the issue left as NOT_ENOUGH_INFO: the app is reparented to PID 1, in its own process group, and survives SIGTERM to open.
  • The same workflow with this change: exit 1, 21.0 s, no root, no surviving process.
  • Regression failure on base: with the base verifier restored, the 4 original entrypoint tests fail. Mutations also fail the expected tests: removing the retire step (9), sweeping before stopping the launcher (26), removing the ready PID check (1), ignoring a refused cleanup (7).
  • Regression failure for a retry: with the release's earlier cached sweep result restored, the new test fails with the fixture still alive after a retry that had removed the root. In that test a real rename refusal comes first, the first sweep finds nothing, and only then does the pending launch start.
  • Regression failure for a compile: a stand-in that finishes writing into the root 300 ms after SIGINT. With the previous verifier, SIGTERM during either compile exits with it never stopped, and both verifier tests fail. Removing the join fails 3 tests, and using SIGTERM as the stop signal fails 3. The other mutation counts here predate these 3 tests.
  • Regression failure for final cleanup: this PR's first signal handler fails the 4 held-release tests. A repeated signal killed it mid-release (signalCode SIGTERM or SIGINT), and a single signal exited 143 instead of keeping the primary failure's 1. Mutations of the lane also fail, re-measured on the final tests: process.once (6), exit from the signal listener (11), dropping the late status (2), losing primary precedence (4), no forced exit (5).
  • User-visible CLI/MCP output: not applicable.

Lifecycle regressions covered: handshake late, absent and invalid; launch failure with and without a live process; launcher that ignores SIGTERM; cleanup refusal followed by retry; a retirement refused while a launch is pending, with the fixture starting before the retry; mixed outcome where the processes are released and root removal is denied, then retried; close racing an admitted launch; interruption during the handshake and during either compile; interruption during final cleanup with successful and failed release, including repeated signals; a hung operation ended by a signal; joined closes sharing one failed release, then a retry; foreign PID in the ready record.

Remaining proof gaps:

  • Positive capture, actions and CLI/MCP parity did not run. Screen Recording is not granted to this host, so the lane can only reach the permission boundary.
  • The unmodified lane does not complete on this host, independent of this change. Both failures are outside this PR:
    • window-mismatch from the helper stopped all 3 full runs on unmodified main and 4 of 6 on this branch. A control using only the owner, with no lane, reproduced it: the fixture's window was listed before the helper call and gone from CGWindowListCopyWindowInfo (count 0) after the helper's 26-second run. The root cause is not diagnosed.
    • The changed-bytes assertion checks error.message for target-mismatch, but since cc90771af that code sits in error.issues (source-derived, and observed failing here). With only that line changed in a local copy, the lane exited 0 with permission-boundary-only and positive_e2e: false, leaving no root or process. That run predates runNativeUiFixtureLane; all 3 attempts since then stopped at window-mismatch, so the real lane's success path through the new wrapper is unobserved. An authored operation covers it in the tests.
  • App path retirement is an observed result on this macOS version, not a LaunchServices guarantee.
  • A verifier killed by SIGKILL or a crash still leaves its fixture, because no owner survives it. A release that hangs can now only be ended that way. Its steps have their own bounds (source-derived); that bound was not tested.
  • The signal barrier sits at the retire step only. Signals during launcher stop, sweep or root removal reach the same settlement point but are not separately gated.
  • The one-shot verifier does not retry a refused cleanup itself. It exits non-zero and names the root and run. Retry is exercised on the owner.
  • A signal during the verifier's short child-retrieval run does not stop that program. It only prints, does not write to the root, and was gone within 500 ms when observed.
  • Not exercised: Intel macOS, and these tests on Linux, which have not run locally.

For evidence-bearing changes: not applicable; this changes no Evidence.

  • Observed, derived, and inferred claims remain distinguishable.
  • Artifact identity, source provenance, and failed attempts remain preserved.
  • Unsupported, incomplete, unavailable, or uncertain outcomes remain visible.

Validation performed

  • npm run test:focused -- the new test, verifierCleanup, verifierTerminalOwner, darwinProcessRunTokenReader, ciChangeScope, ciRequiredLanes, ciQualification — 497 passed, before adding lifetimePipe; the new test file was re-run after it.
  • The new test file alone, 5 consecutive runs with 10 busy loops on this 10-core host (load average up to about 29) — 35 passed in each run, before the compile tests were added.
  • npm run check:fast — passed.
  • npm run format:check — passed.
  • npm run docs:check — passed.
  • Real /usr/bin/open and LaunchServices, final code: never-ready fixture as above; 4 of 4 SIGTERMs sent as soon as open appeared exited 143 with no root and no survivors; SIGTERM during each real swiftc compile exited 143 with no root and no swift process left; with the release held, SIGTERM, SIGTERM and SIGINT left the verifier alive with its root present, and after the gate opened it exited 1 with the primary failure and no root or survivor. Owner code unchanged since these: the real ui.swift fixture reached ready in 1.1 s and closed in 435 ms, with no root or process left, and no crash report named the fixture.

Compatibility, safety, and release

  • Breaking changes or migration steps: none.
  • Real Hopper/Ghidra, browser, or OS coverage: macOS 26.7.1 arm64 real LaunchServices as above; no provider involved.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: the token is a random UUID held only in the launcher's and fixture's environment. A forged ready record gets no signal authority, which the foreign PID test covers.

Author readiness

Human owner: @kaoru0822-kitauji

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

Declarations entered by Claude Code on the owner's standing written instruction.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • User-visible CLI/MCP changes include representative output.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

🤖 Generated with Claude Code

LaunchServices starts the fixture outside the launcher's process tree, and
the verifier learned its PID only from the ready record. A missing handshake
therefore stopped `open`, removed the fixture root and left the app running,
reparented to PID 1.

Own the launch from before it starts: the run token reaches the app through
the launch environment, so the shared sweep finds it with or without a ready
record. Stop the launcher through its retained handle, retire the application
path so an in-flight launch cannot start, sweep, and only then remove the root.
A refused cleanup retains the root and run for retry. The ready PID must prove
the token before it is used, and an interrupted verifier releases its fixture.

Fixes #8559

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kaoru0822-kitauji kaoru0822-kitauji added bug Something isn't working track:bugs Small-bug loop track PRs labels Nov 2, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T10:34:06.951598Z 8247f72 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d9021913c

ℹ️ 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".

Comment on lines +696 to +700
const fixture = await scenario.started();
verifier.child.kill(signal);
expect((await verifier.exit).code, verifier.stderr()).toBe(exitCode);
expect(await isLive(fixture.pid)).toBe(false);
expect(await verifier.roots()).toEqual([]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cover interruption during final cleanup

Both signal tests interrupt the handshake, before final cleanup begins. They do not exercise a signal arriving while withVerifierCleanup is already awaiting fixture.close(), when the new signal handler joins that release and schedules process.exit(). Add a regression that gates an in-progress release, delivers the signal, and verifies that exit waits for cleanup settlement, including retained-root diagnostics when cleanup fails. This finalization coverage is explicitly required for lifecycle changes; handshake cancellation does not substitute for it.

AGENTS.md reference: AGENTS.md:L23-L23

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a code defect as well as missing coverage. Fixed in f2e0525; the branch head is f9160aa, which merges latest main.

Root cause. The signal handlers in verify-native-ui.mjs raced the cleanup they were meant to protect:

  • process.once removed the handler after the first signal, so a repeated signal took its default action and killed the process mid-release. That orphans the fixture and leaves the root. It is an ordinary case, since npm forwarding Ctrl-C can deliver the same signal twice.
  • The handler's own process.exit beat the main path, so a single signal during cleanup exited 143 instead of keeping an existing failure's 1.

Exit did wait for a joined release, and a failed release did keep its root and retry ability, so those parts were already right.

Structural fix. runNativeUiFixtureLane now settles the process outcome in one place, and the verifier runs inside it:

  • A signal only records the interruption and ends the operation's race. The handler no longer exits the process.
  • The release always runs to settlement through withVerifierCleanup, and every signal stays handled until it settles. SIGKILL remains the override.
  • The first failure selects the status, and every failure is reported. A signal after a failed operation keeps exit 1 and the primary failure. A signal after a completed operation keeps the report on stdout and exits 130 or 143. If the release then fails, the report is lost and the retained root and run are reported instead, still with 130 or 143.

Validation.

  • New tests park the real release at a barrier inside its retire step, then deliver the signals, with both successful and failed release outcomes. They cover repeated and mixed signals, a hung operation, and joined closes followed by a retry.
  • This PR's first handler fails the 4 held-release tests on the actual verifier: a repeated signal left signalCode SIGTERM or SIGINT, and a single signal gave 143 instead of 1.
  • Mutations also fail: process.once (6), exit from the listener (11), dropping the late status (2), losing primary precedence (4), no forced exit (5).
  • test:focused on the new file and 6 sibling files: 493 passed, in each of 2 runs after merging main. check:fast, format:check and docs:check pass.
  • Real /usr/bin/open and LaunchServices with the release held: SIGTERM, SIGTERM and SIGINT left the verifier alive with its root present. After the gate opened it exited 1 with the primary failure, and no root or fixture remained.

Remaining.

  • The barrier sits at the retire step only. Signals during launcher stop, sweep or root removal reach the same settlement point but are not separately gated.
  • A release that hangs can now only be ended with SIGKILL. Its steps have their own bounds (source-derived); that bound was not tested.
  • The real lane's success path through the new wrapper is unobserved, because a pre-existing window-mismatch stopped all 3 attempts since the change. It also stopped all 3 runs on unmodified main. An authored operation covers that path in the tests. Details are in the PR body.

Leaving this thread open for review.

kaoru0822-kitauji and others added 2 commits November 2, 2026 07:09
The verifier's signal handlers raced its own cleanup. `process.once` left a
repeated signal to its default action, which killed the process mid-release
and orphaned the fixture; npm forwarding Ctrl-C makes that repeat ordinary.
The handler's own `process.exit` also beat the main path, so a signal during
cleanup replaced an existing failure with an interrupted status.

Settle the process outcome in one place. A signal now only records the
interruption and ends the operation's race, the release always runs to
settlement, and every signal stays handled until then. The first failure
selects the status and every failure is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Low impact] The reviewed changes appear safe to merge, with all three previous findings addressed.

Summary

The verifier now owns its fixture before launch and keeps the root until cleanup succeeds. The latest change also brings both Swift compiles under that owner.

  • The verifier keeps ownership of its UI fixture through cleanup.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Create fixture owner and root] --> B[Compile through owner]
  B --> C[Launch with run token]
  C --> D[Check ready PID and run token]
  D --> E[Run verifier checks]
  B --> F[Close owner]
  C --> F
  E --> F
  F --> G[Abort and join admitted work]
  G --> H[Stop launcher]
  H --> I[Retire application path]
  I --> J[Sweep token-owned processes]
  J --> K{All cleanup steps succeeded?}
  K -->|Yes| L[Remove root]
  K -->|No| M[Report failure and retain root for retry]
Loading

Reviews (6) · Last reviewed commit: "fix(native): stop and join verifier comp..." · Reviewed by Greptile

Comment on lines +415 to +418
// The application path was gone while the root still existed.
expect(
(await scenario.events()).find(({ kind }) => kind === "launch-refused"),
).toMatchObject({ rootPresent: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Correct cleanup can fail CI

The rootPresent: true assertion relies on the launch-service process running between application retirement and root removal. Its 2 ms timer does not guarantee that. If that process is delayed, owner.close() can finish the sweep and remove the root before the timer runs, so correct cleanup fails this test.

Add an explicit acknowledgment before allowing root removal, or check the ordering at a held retirement step.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/boundary/process/nativeUiVerifierFixtureOwnership.test.ts
Line: 415-418

Comment:
**Correct cleanup can fail CI**

The `rootPresent: true` assertion relies on the launch-service process running between application retirement and root removal. Its 2 ms timer does not guarantee that. If that process is delayed, `owner.close()` can finish the sweep and remove the root before the timer runs, so correct cleanup fails this test.

Add an explicit acknowledgment before allowing root removal, or check the ordering at a held retirement step.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid; this was a flake in a test I added, and it reproduced. With the stand-in launch service's poll stretched from 2 ms to 2 s, the old test failed on rootPresent even though cleanup was correct. Fixed in 48bb585; the branch head is 9a8733e, which merges latest main.

Root cause. The test read the ordering from a separate process polling on a timer. Nothing kept the root in place until that process had looked, so its scheduling decided the assertion.

Fix.

  • The test now runs in a child process with a barrier in the real release, just before root removal. Removal stays held until the test has seen the launch refused. At that point it checks directly that the app path is gone, its .retired path exists and the root is present, then opens the gate.
  • The barrier is a preload wrapper around fs/promises.rm, in the same form as the retire barrier. The release itself is not mocked.
  • Moving to a child process exposed a second dependency: the 300 ms handshake budget could stop the launcher before it had started the launch service. The operation now waits until the launch is admitted, then fails, so release cannot start early.

Others of the same kind in this file and the lifecycle module.

  • Removed the elapsed-time bound in the launcher-refusal test. Its exit message already proves the exit path rather than the deadline path.
  • Raised the stand-ins' 25 s safety expiries to 60 s, because they were shorter than the 30 to 40 s test timeouts and a slow but correct run could lose a fixture the test expects alive.
  • Raised the launcher diagnostics grace from 1 s to 5 s, so a stalled event loop cannot drop stderr from a startup failure. This is the one production constant changed, and it only applies once a launcher has exited before the handshake.
  • Left alone: the delay(150) between signals and the outlived-root poll, because lateness there can only miss a defect and cannot fail correct cleanup. The 300 ms budgets in the module-level tests are also fine, because those fixtures never answer and the tests wait for the fixture before closing.

Validation.

  • With the same 2 s poll, the new test passes.
  • Removing the retire step fails it with Timed out waiting for the in-flight launch to lose its application. It also fails the 8 tests whose barrier sits on that step.
  • The test file ran 5 consecutive times with 10 busy loops on this 10-core host (load average up to about 19): 34 passed each time.
  • After merging main: the file plus 6 sibling files, 493 passed; check:fast and format:check pass.

Remaining. Those loaded runs were on macOS only, so these tests have not been run on Linux locally. CI covers that.

Leaving this thread open for review.

kaoru0822-kitauji and others added 2 commits November 2, 2026 08:12
The in-flight launch test read `rootPresent` from a stand-in polling every 2 ms,
so a delayed stand-in could see the root already gone and fail correct cleanup.
Hold the real release just before root removal until the test has seen the launch
refused, and start release only once the launch is admitted, which the 300 ms
handshake budget did not ensure in a separate process.

Remove the same kind of dependency elsewhere in the file: an elapsed-time bound
the exit message already proves, and safety expiries shorter than the test
timeouts. The launcher diagnostics grace also becomes 5 s, so a stalled event loop
cannot drop the launcher's stderr from a startup failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +221 to +227
if (this.#ownership === undefined || this.#familyReleased) return;
const result = await cleanupOwnedProcessGroup(this.#ownership);
if (!result.cleaned)
throw new Error(`Native UI fixture processes: ${result.reason}`, {
cause: result,
});
this.#familyReleased = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retry misses a late fixture

#sweepFixture() sets #familyReleased even when application retirement failed earlier in the same cleanup attempt. If rename() is refused while LaunchServices still has a launch pending, the sweep can find nothing and cache success. The app can then start before cleanup is retried. That retry retires the path but skips the sweep and deletes the root beneath the running fixture.

Only cache family release after the launcher has stopped and the application path has retired, or sweep again on each failed-release retry. The current behavior also violates the repository requirement to retain ownership when cleanup fails.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/lib/native-ui-fixture-lifecycle.mjs
Line: 221-227

Comment:
**Retry misses a late fixture**

`#sweepFixture()` sets `#familyReleased` even when application retirement failed earlier in the same cleanup attempt. If `rename()` is refused while LaunchServices still has a launch pending, the sweep can find nothing and cache success. The app can then start before cleanup is retried. That retry retires the path but skips the sweep and deletes the root beneath the running fixture.

Only cache family release after the launcher has stopped and the application path has retired, or sweep again on each failed-release retry. The current behavior also violates the repository requirement to retain ownership when cleanup fails.

**Context Used:** AGENTS.md ([source](https://github.com/morluto/rea/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This held: it was a logic bug in the code I added. Fixed in 61e8653.

Root cause. #sweepFixture cached a clean result in #familyReleased, even when retiring the application path had failed earlier in the same attempt. A clean sweep says nothing while a launch can still start. So a refused rename could cache success, the app could then start, and the retry skipped the sweep and removed the root beneath the running fixture.

Structural fix. I removed the flag rather than adding a condition to it. The invariant is now one sentence: every release attempt stops the launcher, retires the path and sweeps again, and only an attempt whose steps all succeeded removes the root.

Other cached state in the class.

  • ProviderProcessSupervisor.stop() keeps a completed result, which is safe because an observed launcher exit cannot be undone.
  • Retirement has no flag. Retrying it after success sees ENOENT, which is accepted.
  • #released is set only after root removal succeeds.
  • Nothing else can be recorded as done while an earlier step failed.

A retry after only a root removal failure now repeats a sweep that finds nothing. That is the cost of removing the flag, and it applies only on a retry.

Validation.

  • New regression at the real owner, with no mock: a read-only root gives a real EACCES on the rename while the launch is pending. The first sweep finds nothing, then the launch starts and the root is confirmed still present. The retry must kill the fixture and only then remove the root, and no outlived-root event may appear.
  • With the previous head's lifecycle module restored, that test fails with the fixture still alive (expected true to be false).
  • Mutations, re-measured on the final tests, still fail: no retire step (9), sweep before stopping the launcher (26), no ready PID check (1), ignoring a refused cleanup (7), process.once (6), exit from the listener (11), late status dropped (2), primary precedence lost (4), no forced exit (5), this PR's first signal handler (4).
  • The test file ran 5 consecutive times with 10 busy loops on this 10-core host (load average up to about 29): 35 passed each time.
  • After merging main: the file plus 6 sibling files, 494 passed; check:fast and format:check pass.

Remaining. The refusal here is a read-only directory standing in for whatever might refuse the rename, and the launch service is an authored stand-in, so this does not establish LaunchServices timing. These tests have not been run on Linux locally; CI covers that.

Leaving this thread open for review.

kaoru0822-kitauji and others added 3 commits November 2, 2026 08:30
A clean sweep was cached even when retiring the application path had failed in
the same attempt. If the rename was refused while a launch was pending, the sweep
found nothing, the app then started, and the retry skipped the sweep and removed
the root beneath the running fixture.

Remove the cached flag. A sweep proves nothing while a launch can still start, so
every attempt sweeps again, and only an attempt whose steps all succeeded removes
the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A signal during either Swift compile ended the operation's race while the compiler
kept running, so release removed the root beneath it and the verifier exited without
stopping it. On base, the same signal left the root behind instead.

Run both compiles through the fixture owner with the existing `execFileOutput` and
`SWIFTC_INTERRUPT` path. Closing the owner now stops them as swiftc requires, and
release joins them before it touches the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working track:bugs Small-bug loop track PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Native UI verifier can lose fixture ownership before the ready handshake

1 participant