Skip to content

refactor(ios): name the runner session xctestrun as a process-identity contract - #2987

Merged
thymikee merged 3 commits into
mainfrom
refactor/runner-session-xctestrun-name-contract
Sep 26, 2026
Merged

thymikee merged 3 commits into
mainfrom
refactor/runner-session-xctestrun-name-contract

Conversation

@thymikee

@thymikee thymikee commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-up to #2963 from the same inspection pass: what does runner process cleanup actually depend on, and is it declared anywhere?

A runner launch is killed by matching xcodebuild's argv with pkill -f, and that argv carries the per-session .xctestrun filename. The filename is a process-identity contract, but it was spelled three times: inline in the session, inline in disposal, and again in the daemon-client timeout sweep. Disposal escaped the device id for regex without flattening it the way the writer's filesystem sanitization does, so a device id needing flattening built a pattern that could not match the file the writer had already written.

runner-artifact-env.ts now owns that name: the stem, the suffix field order, one sanitizer, and two pkill -f builders. The writer and the session go through the suffix builder; the sanitizer is shared rather than re-spelled.

Two patterns, because the two callers know different things:

  • A lease-backed reclaim follows the artifact the lease recorded. RunnerXcodebuildCleanupTarget (runner-lease.ts) carries the lease's xctestrunPath, and the pattern is that basename. This closes a gap the reviewer found: buildDetachedRunnerLease rewrites ownerToken to detached-<token> while the handed-over xcodebuild keeps running under the name the writer gave it, so the token-derived pattern named a file that never existed and only the pid-tree kill covered that launch.
  • A reclaim with no lease to read sweeps by device, keeping the released pre-owner-token bytes (session-<device>-[0-9]).

Nothing derives a launch name from the owner token anymore, so the token branch of the old builder is gone rather than left unreached.

The name lives in the writer, not a shared contracts module, because the timeout sweep must not follow a rename: it ships separately and has to keep matching names older writers used, so it keeps a pinned literal. That also keeps the eager-closure budget (#1960) at zero new edges — the first shape of this PR added a contracts module and the gate rejected it on four entries.

Also records three tooling constraints from xcodebuild(1) / simctl help, checked on Xcode 27.1: only TEST_RUNNER_-prefixed names cross into the test runner; simctl launch --stdout/--stderr resolve inside the device's data container; and xcodebuild re-synthesizes attachment lifetimes from its own defaults (plan sets keepNever, built xctestrun carries SystemAttachmentLifetime=deleteOnSuccess).

13 files. Production +131/−20, tests +272/−11.

Validation

Head a11fba324:

  • pnpm check:affected --run passes (3031 tests, 429 files): eager-closure budgets green, layering guard OK, check:fallow --base origin/main clean in changed files, and the test-file size ratchet green — runner-session.test.ts is back at its merge-base length. Also passing: typecheck, lint, format:check.
  • apple-runner project 631 tests and unit-core daemon-client + eager-closure 805 tests green.
  • Byte parity: old vs new suffix and device-sweep patterns compared for concrete, host-style, and flattening device ids — equal everywhere except the flattening case, where the old pattern failed to match its own file (the defect fixed here).
  • Detached-lease coverage is load-bearing: replacing the path builder with the device sweep in killRunnerXcodebuildProcesses fails a leased cleanup selects the launch named by the artifact the lease recorded in runner-disposal.test.ts, which drives the production adapter and asserts the emitted pkill bytes against the launch argv.
  • Mutation check: renaming the stem fails 13 tests across the writer, the lease pattern, and the sweep's pinned literal. The daemon-client route test stays green by design — its literal must not follow a rename, and runner-xctestrun.test.ts is what binds that literal to what the writer actually emits today.
  • Pins are literals, not derived. xcodebuild .*AgentDeviceRunner\.env\.session- is asserted to still match both owner-token and pre-owner-token (session-<device>-<port>, shipped through v0.17.0) names.
  • Names and argv are unchanged, so no device-facing change and no simulator run is claimed. unit-ci, integration, and Swift runner lanes stay GitHub-authoritative.

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.85 MB 4.85 MB +224 B
Package (unpacked) 4.85 MB 4.85 MB +224 B
Package (download) 1.45 MB 1.45 MB +77 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.5 ms 29.4 ms -0.2 ms
CLI --help 87.4 ms 86.5 ms -0.9 ms

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/contracts/src/runner-session-artifact.ts">

<violation number="1" location="packages/contracts/src/runner-session-artifact.ts:57">
P3: The contract's sanitizer is private, yet the writer at `packages/platform-apple/src/runner/runner-artifact-env.ts:48` still spells the same rule itself (`suffix.replaceAll(/[^a-zA-Z0-9._-]/g, '_')`). Two copies of the `[a-zA-Z0-9._-]` character set now govern on-disk session names; they're identical and the writer's pass is idempotent today, but any future edit to one copy silently renames sessions while the matchers keep the old bytes — exactly the drift this PR exists to prevent. Export `sanitizeRunnerSessionNameField` and have the writer consume it, or delete the writer's re-sanitize pass.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic


/** Characters a session name may carry; anything else is flattened, as the filesystem writer does. */
function sanitizeRunnerSessionNameField(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_');

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The contract's sanitizer is private, yet the writer at packages/platform-apple/src/runner/runner-artifact-env.ts:48 still spells the same rule itself (suffix.replaceAll(/[^a-zA-Z0-9._-]/g, '_')). Two copies of the [a-zA-Z0-9._-] character set now govern on-disk session names; they're identical and the writer's pass is idempotent today, but any future edit to one copy silently renames sessions while the matchers keep the old bytes — exactly the drift this PR exists to prevent. Export sanitizeRunnerSessionNameField and have the writer consume it, or delete the writer's re-sanitize pass.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/contracts/src/runner-session-artifact.ts, line 57:

<comment>The contract's sanitizer is private, yet the writer at `packages/platform-apple/src/runner/runner-artifact-env.ts:48` still spells the same rule itself (`suffix.replaceAll(/[^a-zA-Z0-9._-]/g, '_')`). Two copies of the `[a-zA-Z0-9._-]` character set now govern on-disk session names; they're identical and the writer's pass is idempotent today, but any future edit to one copy silently renames sessions while the matchers keep the old bytes — exactly the drift this PR exists to prevent. Export `sanitizeRunnerSessionNameField` and have the writer consume it, or delete the writer's re-sanitize pass.</comment>

<file context>
@@ -0,0 +1,62 @@
+
+/** Characters a session name may carry; anything else is flattened, as the filesystem writer does. */
+function sanitizeRunnerSessionNameField(value: string): string {
+  return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
+}
+
</file context>
Fix with cubic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed at 0683a3d, and the file this comment sits on no longer exists. There is one sanitizeRunnerSessionNameField in packages/platform-apple/src/runner/runner-artifact-env.ts: the suffix builder applies it, and prepareXctestrunWithEnv joins the stem to the already-sanitized suffix through the same function instead of re-spelling the character set.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 25f05bd. The rename to a process-identity contract looks correct. For realistic device ids, the runner argv and the disposal pkill pattern keep the same bytes. The only behavior change is the matcher for device ids outside the sanitized charset. I found no blocking defects. There are no conflicts.

Before this goes further, one design question. The new contracts subpath is justified, because src/daemon-client cannot import platform-apple. But does it need the whole name builder? A smaller contract could export only the stem and the name-prefix pattern and let the writer own the one sanitizer. Then lease-backed disposal could match the lease's recorded xctestrunPath basename directly, instead of rebuilding <device>-<token>- from parts. That would also close the detached-lease gap below. What stops that smaller shape? It would need the disposal adapter to take the lease (or its xctestrunPath) instead of (deviceId, ownerToken).

A pre-existing gap inside the invariant this PR names: a detached lease rewrites ownerToken to detached-<token>, so the reclaim pkill at runner-lease.ts#L554 builds a prefix that never matches the written xctestrun name. Only the pid-tree kill covers it. This is not a regression from this PR; a follow-up is fine.

Smaller notes, not blocking: the writer still re-sanitizes the suffix with its own regex (runner-artifact-env.ts#L48); the round-trip test comment claims more than it pins, because a stem or prefix rename would pass on both sides (runner-xctestrun.test.ts#L366); and the dead AGENT_DEVICE_RUNNER_PORT entry (runner-process-launch.ts#L88) is outside this change.

Smoke Tests, Repo Guards, Coverage and Integration were still running. They all exercise this diff, so a failure there is likely related until shown otherwise. No live simulator run is needed: argv and pkill bytes are unchanged for real device ids.

…identity contract

A runner launch is killed by matching `xcodebuild`'s argv with `pkill -f`, and
that argv carries the per-session xctestrun's filename. The filename is
therefore a process-identity contract, but it was spelled three times: inline in
the session, inline in disposal, and again in the daemon-client timeout sweep.
Disposal escaped the device id for regex without flattening it the way the
writer's filesystem sanitization does, so a device id needing flattening built a
cleanup pattern that could not match the file the writer had already written.

Name it once. `runner-artifact-env.ts` now owns the stem, the suffix field
order, the sanitization, and the `pkill -f` pattern derived from those same
parts, and the writer, the session, and disposal all go through it. The name
lives in the writer rather than a shared contracts module because the timeout
sweep must not follow a rename: it ships separately, cannot know which version
named a timed-out launch, and has to keep matching the names older writers used.
So the sweep keeps a pinned literal, and its test proves those bytes still
select both the owner-token and the pre-owner-token spelling. Placing the name
in the module the Apple façades already evaluate also holds the eager-closure
budget (#1960) at zero new edges.

Verified byte-for-byte against the previous patterns for concrete, host-style,
and flattened device ids. The only divergence is the flattened case, where the
old pattern failed to match its own file.

Also record three tooling constraints read from `xcodebuild(1)` and `simctl
help`, and checked on Xcode 27.1: only `TEST_RUNNER_`-prefixed names cross into
the test runner; `simctl launch --stdout/--stderr` resolve their paths inside
the device's data container, so `--console-pty` is the console mode this host
path can use; and xcodebuild re-synthesizes the attachment-lifetime keys from
its own defaults, since a plan setting `keepNever` still builds an xctestrun
carrying `SystemAttachmentLifetime=deleteOnSuccess`.
@thymikee
thymikee force-pushed the refactor/runner-session-xctestrun-name-contract branch from 25f05bd to 0683a3d Compare September 25, 2026 20:14
…se recorded

A detached lease rewrites its owner token to `detached-<token>` while the
xcodebuild it handed over keeps running under the name the writer gave it, so
the token-derived pkill pattern named a file that never existed and the launch
survived the reclaim. Lease-backed cleanup now follows the recorded xctestrun
basename, which is exactly the argv the launch carries; the device sweep stays
for the no-lease case, and the token branch of the old builder is removed.
@thymikee

Copy link
Copy Markdown
Member Author

Good call — the smaller shape was the right one, and it also subsumed the detached-lease gap. Pushed as f1e27f1.

The contracts subpath is gone entirely. src/daemon-client keeps a literal for its pkill bytes and a test pins both name eras against the writer, so no cross-package import was needed. What remains is two named builders in packages/platform-apple/src/runner/runner-artifact-env.ts (runner-artifact-env.ts:40, :53, :68) and the writer's own sanitizeRunnerSessionNameField.

Lease-backed disposal now matches the recorded basename, exactly as you sketched: the adapter takes a target instead of (deviceId, ownerToken):

  • RunnerXcodebuildCleanupTarget (runner-lease.ts:105) is { deviceId } plus an optional recorded xctestrunPath.
  • The lease path passes lease.xctestrunPath (runner-lease.ts:562 area); the empty-lease reclaim passes only the device and keeps the released bytes.
  • killRunnerXcodebuildProcesses prefers buildRunnerSessionXctestrunPathCleanupPattern and falls back to the device sweep (runner-disposal.ts:338).

That builder returns undefined unless the basename carries the session stem: a recorded runner.xctestrun would escape into a pattern loose enough to signal unrelated xcodebuilds, so such leases fall back to the device sweep.

Because nothing reads the owner token anymore, I deleted the token branch of the old builder rather than leaving it as an unreached path.

Detached-lease gap is closed here, not in a follow-up. Two tests:

  • runner-artifact-env.test.ts:101 spells the bytes a token-derived pattern would have built and asserts they select a file that never existed, then asserts the recorded path does.
  • runner-session.test.ts:739 drives ensureRunnerSession against a detached lease and asserts the real pkill argv the reclaim issued matches the launch's argv. I verified it is load-bearing: reverting only the two production files fails it with ...-detached-owner-4242-ab12cd34- against the launch's ...-owner-4242-ab12cd34-8123.xctestrun.

Your smaller notes:

  • Writer re-sanitizing with its own regex: resolved at 0683a3d — the writer joins through the same sanitizeRunnerSessionNameField the suffix builder uses.
  • Round-trip comment overclaiming: the identity test now pins literal bytes on both sides (the sweep's String.raw copy and the lease pattern), so a rename that keeps each side self-consistent fails. Mutation-checked: renaming the stem fails 5 tests.
  • Dead AGENT_DEVICE_RUNNER_PORT: agreed it is outside this change; I left it alone and will open it separately.

Gates at f1e27f1: check:affected --run (3030 tests), typecheck, lint, format, apple-runner suite (630), daemon-client + eager-closure budgets (805) all pass. On the CI note: the earlier smoke failure at 0683a3d (wait timed out for text: Agent Device Tester, readinessPhase: "runner-start") reproduces on base main in run 36163937992 (wait timed out for text: Automation lab, same phase), so it is not this diff.

@thymikee

Copy link
Copy Markdown
Member Author

Two reach differences from the lease shape, worth stating explicitly since the byte-parity review was about what the pattern selects:

The lease pattern is now port-exact. The old token-prefix pattern (session-<device>-<token>-) also reached a same-token launch on another port. Nothing can produce such a launch that a lease does not name: the port is allocated inside one startRunnerSessionWithLease call, and that call writes the one lease recording it. The only rewrite that outlives a launch is buildDetachedRunnerLease, which rewrites the token and keeps the path. So the extra reach selected at most a launch no lease pointed at, and a reclaim with no lease to read already never had it (it gets the device sweep). If you want that slack kept, the builder can emit session-<device>-<token>-[0-9] from the recorded basename instead of the whole name — say so and I'll switch it.

The device id no longer appears in the lease pattern (it still scopes the sweep). It is inside the basename, so the device is still selected; the difference is only for a lease whose deviceId and recorded path disagree, which needs a hand-edited or moved lease file. For that lease the reclaim already deletes lease.xctestrunPath and lease.jsonPath, so signalling the launch that artifact names is the same scope, not a wider one.

…ilds it

Driving `runnerLeaseCleanupAdapter` directly proves the pkill bytes disposal
issues select the launch a lease names, without session-startup choreography.
Moves the detached-lease coverage out of the aggregated session suite, which
the test-file size ratchet forbids growing.
@thymikee

Copy link
Copy Markdown
Member Author

One correction to my earlier comment: I cited runner-session.test.ts:739 for the detached-lease regression, and that test no longer lives there.

runner-session.test.ts is over the 1,000-line test ratchet at its merge-base length (1,549), so it may not grow. The version I pushed there did grow it, and the Coverage lane said so — the ratchet runs in that job, not Repo Guards. Fixed at a11fba3: runner-session.test.ts is back to exactly 1,549 lines, and the coverage claim moved to the module that owns it.

runner-disposal.test.ts now drives runnerLeaseCleanupAdapter directly and asserts the pkill bytes disposal emits against the launch argv. That is a better home than session choreography: it is the same assertion the reviewer's gap needs, minus the startup sequence that could drift, and it exercises the exported adapter rather than a mock recording a token. runner-artifact-env.test.ts:101 keeps the byte-level side, spelling the token-derived bytes literally to show they select nothing.

Verified the relocated test still earns its place: swapping the path builder for the device sweep in killRunnerXcodebuildProcesses fails it.

@thymikee

Copy link
Copy Markdown
Member Author

This is ready for human review. CI is green, all 19 checks pass at a11fba3, including the Coverage lane that runs the test-file size ratchet, so no failures need attribution.

The delta answers the earlier evidence-pending state: the detached-lease gap is closed, and reuse and teardown ownership are unchanged, so the same lease owner and the same two adapter callers still apply.

I did not run a live xcodebuild to confirm the pkill matches a real detached process; I checked the pattern by reading it as an ERE-escaped literal of a basename limited to [a-zA-Z0-9._-] against the fixture bytes, and confirmed that literal matches the JS RegExp the tests use. The claim that port-exact narrowing drops no live orphan is reasoned rather than exhaustively traced: a same-token launch on another port whose lease was overwritten by a later launch from the same daemon would not be reached by the reclaim pkill, and while I found no production path that leaves such a launch alive, I did not trace every startup-failure branch. I also did not run the author's own mutation checks (stem rename, swapping the path builder for the device sweep) locally.

Not blocking: the stale-lease assertion at packages/platform-apple/src/runner/tests/runner-session.test.ts#L787 could be tightened to pin the full target including xctestrunPath the way it used to pin ownerToken, so a dropped xctestrunPath falling back to the device sweep would still fail it, and the test name at packages/platform-apple/src/runner/tests/runner-artifact-env.test.ts#L29 could be corrected to say the recorded basename is what gets selected rather than claiming released daemons get pkilled by the token prefix — both can be taken or left.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 26, 2026
@thymikee
thymikee merged commit 26db9d0 into main Sep 26, 2026
19 checks passed
@thymikee
thymikee deleted the refactor/runner-session-xctestrun-name-contract branch September 26, 2026 12:59
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-26 12:59 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant