Skip to content

fix(test): tolerate dead session in iOS e2e full-tier cleanup - #1548

Merged
thymikee merged 3 commits into
mainfrom
claude/sweet-colden-3e66ff
Aug 2, 2026
Merged

fix(test): tolerate dead session in iOS e2e full-tier cleanup#1548
thymikee merged 3 commits into
mainfrom
claude/sweet-colden-3e66ff

Conversation

@thymikee

@thymikee thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

What

Makes the iOS simulator e2e harness cleanup tolerate a session that died mid-run, in two layers:

  • finalizeLiveRun now re-checks sessionExists() after the scenario loop instead of short-circuiting on context.sessionOpen (which was already true from earlier in the run). The daemon's session list is authoritative at finalize time; if the session is gone, session-scoped cleanup is skipped entirely.
  • cleanupSession treats a step failure with SESSION_NOT_FOUND or the appless-session INVALID_ARGS message (requires an active app in session, the exact string from app-settings.ts) as already-clean and stops retrying that step. Any other persistent failure still exhausts three attempts and fails loudly through the existing assert.fail path.

Why

On a local full-tier run, all 10 scenarios pass but the run still ends red: full:device-lifecycle shuts down and reboots the simulator, the daemon session does not survive that locally, and cleanup then fails all three retries of every step (permission setting requires an active app in session for the permission reset; No active session for orientation and close). CI is green with identical code — the session-survives-reboot assumption is environment-sensitive, so the harness shouldn't depend on it.

Notes for reviewers

  • CI behavior is unchanged: there the session survives the reboot, sessionExists returns true, and cleanup steps run and succeed as before.
  • In the mixed case (session lease survives, app dead), settings appearance needs no app bundle (plain simctl ui) so it still succeeds; only the permission reset short-circuits as already-clean.
  • The Android runner has the same sessionOpen || pattern but no scenario that reboots the device, so it's left alone.
  • Verified with typecheck/lint/format plus module smoke-import; the failing path only manifests at the end of a full-tier live run, which needs a fixture app build and dedicated simulator.

full:device-lifecycle reboots the simulator, and whether the daemon
session survives that is environment-sensitive: it does on CI but not
locally, so every all-green local full-tier run ended red in cleanup
with all three retries of each step failing ('permission setting
requires an active app in session' / 'No active session').

Two layers:
- finalizeLiveRun re-checks sessionExists instead of short-circuiting
  on sessionOpen, so a session that died mid-run skips cleanup entirely.
- cleanupSession treats SESSION_NOT_FOUND and the appless-session
  INVALID_ARGS failure as already-clean instead of burning retries.

Other cleanup failures still exhaust three attempts and fail loudly.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.92 MB 1.92 MB 0 B
JS gzip 615.7 kB 615.7 kB 0 B
npm tarball 733.8 kB 733.8 kB 0 B
npm unpacked 2.57 MB 2.57 MB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 21.5 ms 21.8 ms +0.3 ms
CLI --help 48.9 ms 45.1 ms -3.9 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Blocking at 790ef6d: the final sessionExists() recheck is sound, and treating SESSION_NOT_FOUND as already clean is appropriately typed. Please narrow the new INVALID_ARGS exception, though. String(message).includes(...) applies to every cleanup step and can hide an unrelated future failure; scope it to the microphone-permission reset, INVALID_ARGS, and the exact known appless-session error (or a structured reason if one is available), with an in-code rationale for the unavoidable message contract.

Please also add a deterministic regression covering: a dead session skips cleanup, the known permission-reset response stops retrying, and a different INVALID_ARGS still fails after retries. The current green smoke checks do not execute the local reboot/dead-session branch. Attach an exact-head full-tier run/artifact showing the previously red cleanup now completes green.

… step

Scope sessionAlreadyClean's INVALID_ARGS tolerance to the microphone-
permission reset step and its exact known message instead of matching
any cleanup step whose message contains "requires an active app in
session" — that substring is also thrown by the unrelated location
setting, so the old check could have hidden a real failure there.

Extract the per-step retry policy into an exported retryCleanupStep so
it's unit-testable without spawning the CLI, and add a deterministic
regression (test/integration/ios-simulator-e2e-cleanup.test.ts)
covering: a dead session (SESSION_NOT_FOUND) stops retrying on any
step, the known mic-permission appless response stops retrying, and a
different INVALID_ARGS (wrong step or wrong message) still exhausts
all three retries and fails.
@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Addressed the blocking review at 790ef6d:

Narrowed INVALID_ARGS guard

sessionAlreadyClean (in test/integration/ios-simulator-e2e/live-harness.ts) no longer matches any cleanup step whose message contains "requires an active app in session". It now tolerates INVALID_ARGS only when all three hold:

  • the failing step is the microphone-permission reset step (reset microphone permission, sharing a constant with the step list so the two can't drift)
  • result.json.error.code === 'INVALID_ARGS'
  • result.json.error.message === 'permission setting requires an active app in session' (exact match, not substring)

I checked whether the daemon error carries a structured field that identifies this case without string matching (ADR 0010 details.reason), but the app-settings.ts call site doesn't set one, so an exact-message match is unavoidable — documented in a 2-line in-code comment above the guard. SESSION_NOT_FOUND tolerance is unchanged (unscoped, as you noted is already correctly typed).

The retry policy itself was extracted into an exported retryCleanupStep(step, runAttempt) so it's unit-testable without spawning the CLI.

Deterministic regression

New file: test/integration/ios-simulator-e2e-cleanup.test.ts (Node test runner, matching this repo's test/integration/*.ts convention — mocked timers via t.mock.timers so the retry-delay cases run in milliseconds). Four cases:

  1. Dead session (SESSION_NOT_FOUND) skips cleanup — stops after attempt 1, no retry, on any step.
  2. Known mic-permission appless response stops retrying — stops after attempt 1 with the real message.
  3. A different INVALID_ARGS on a different step still fails after 3 attempts — same message reused on restore portrait orientation to prove step-scoping.
  4. A different INVALID_ARGS message on the mic-permission step still fails after 3 attempts — uses the sibling location setting requires an active app in session message (the actual other call site sharing that substring) to prove exact-match, not substring.

Counterfactual per docs/agents/testing.md: I reverted sessionAlreadyClean to the original unscoped .includes(...) check and reran the suite. Cases 1 and 2 still passed (unaffected); cases 3 and 4 both failed exactly as expected:

✖ a different INVALID_ARGS still fails after exhausting retries (1.6ms)
  AssertionError: expected a propagated failure, got undefined
✖ a different INVALID_ARGS message on the mic-permission step still fails after retries (0.5ms)
  AssertionError: expected a propagated failure, got undefined

Restored the narrowed guard and reran — all 4 green again. pnpm typecheck && pnpm lint && pnpm format:check and pnpm check:fallow --base origin/main are clean at the pushed head.

Live evidence

Exact pushed head: 9444dd4b68fcb3292dd95cff10df76703889f682

pnpm build && pnpm clean:daemon
AGENT_DEVICE_IOS_E2E=1 \
AGENT_DEVICE_IOS_E2E_TIER=full \
AGENT_DEVICE_IOS_UDID=<simulator-udid> \
AGENT_DEVICE_FIXTURE_APP_PATH=<fixture .app> \
AGENT_DEVICE_FIXTURE_APP_ID=com.callstack.agentdevicelab \
AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE='agent-device-test-app:///automation?event={event}&payload={payload}' \
node --experimental-strip-types --test test/integration/smoke-ios-simulator-coverage.test.ts test/integration/smoke-ios-simulator.test.ts

Run on a dedicated, freshly-cloned "iPhone 17 Pro" simulator (isolated from other concurrent sessions on the host). First attempt failed at full:fixture-replays (gesture-lab.ad fling-gesture wait timeout) — reran once per the repo's flake-attribution policy: same failure again, so not a contention flake; traced it to a fresh-simulator config gotcha (ConnectHardwareKeyboard global preference and, separately, an initial run against an iPhone 17 Pro Max clone that isn't the documented default device — both host-setup artifacts, not the code under review). Fixed both and reran clean.

Result: 13/13 pass, including all 4 full-tier scenarios: full:lifecycle-system, full:observability-artifacts, full:fixture-replays, full:device-lifecycle (the reboot + cleanup scenario this PR targets). This local environment reproduces the original bug precisely: full:device-lifecycle's shutdown/reboot does kill the session here (sessionExists at finalize returns false), so cleanup is skipped entirely — zero cleanup steps recorded, no cleanup-error.txt, previously-red now green.

Artifacts: test/artifacts/ios-simulator/full/1785659131844-54036/ (step-history.json, coverage-report.json, fixture.mp4, screenshots, fixture-replays JUnit).

Generated by Claude Code

@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Correction on the evidence comment above: the cited artifact directory (test/artifacts/ios-simulator/full/1785659131844-54036/) lived inside the temporary worktree and was deleted with it during cleanup — the path no longer exists. The inline results in that comment (exact head 9444dd4b6, 13/13 scenarios incl. the device-lifecycle reboot reproducing the original dead-session state and cleanup skipping cleanly) are the surviving record. If reviewable artifact files are required rather than the inline record, say so and I'll rerun the full tier at the same head with the artifacts preserved outside the worktree.

Generated by Claude Code

@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

P2 — the new deterministic suite still does not cover the principal finalizeLiveRun regression. The production fix replaces context.sessionOpen || sessionExists(context) with an authoritative final sessionExists() recheck, but all four new tests call only retryCleanupStep. The test named ‘a dead session … skips cleanup’ proves one cleanup step stops retrying after SESSION_NOT_FOUND; it does not prove cleanupSession is never invoked, and that function would continue to later steps. Reverting the finalizeLiveRun change leaves the entire new suite green.

Please expose/inject the small finalization decision seam and add a deterministic counterfactual where sessionOpen starts true, final sessionExists returns false, and cleanupSession is not called (plus the live-session path if useful). Keep the exact step/code/message INVALID_ARGS tests—they correctly resolve the earlier broad-message concern. The exact-head 13/13 inline full-tier run is sufficient live-device evidence despite the deleted artifact directory, but it cannot replace the specifically requested deterministic regression. All CI is green; withhold ready-for-human until this gap is covered and focused tests pass.

Extract the sessionOpen re-check + conditional cleanupSession call out
of finalizeLiveRun into an exported finalizeSessionCleanup(context,
runSessionExists, runCleanupSession) — same behavior, now driven by
injected callbacks instead of the module-level runStep-backed
bindings, so it's unit-testable without spawning the CLI.

Add two deterministic cases: sessionOpen starts true and the final
sessionExists() resolves false -> cleanupSession is never invoked;
sessionOpen true and sessionExists() resolves true -> cleanupSession
runs (the live path). Counterfactual (reverting the recheck to the old
`sessionOpen || sessionExists(...)` form) turns the first case red as
expected.
@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Addressed the P2 vacuity gap at 9444dd4: the principal production change in finalizeLiveRun (the authoritative sessionExists() recheck replacing sessionOpen || sessionExists(...)) had no deterministic coverage.

Seam

Extracted the decision out of finalizeLiveRun into an exported finalizeSessionCleanup in test/integration/ios-simulator-e2e/live-runner.ts, matching the style already used for retryCleanupStep (required positional callbacks, no optional/defaulted DI params):

export async function finalizeSessionCleanup(
  context: LiveContext,
  runSessionExists: (context: LiveContext) => Promise<boolean>,
  runCleanupSession: (context: LiveContext) => Promise<void>,
): Promise<unknown>

finalizeLiveRun now just calls finalizeSessionCleanup(context, sessionExists, cleanupSession) — no behavior change, same re-check-then-conditionally-cleanup logic, just extracted so it's callable with fakes.

Two new deterministic cases (test/integration/ios-simulator-e2e-cleanup.test.ts)

  1. sessionOpen=true, final sessionExists resolves falsecleanupSession never invoked, context.sessionOpen flips to false to reflect the re-check.
  2. sessionOpen=true, final sessionExists resolves truecleanupSession invoked exactly once (the live/CI path where the session survives).

The existing four retryCleanupStep tests are unchanged.

Counterfactual

Reverted the recheck line in finalizeSessionCleanup back to the old form (context.sessionOpen = context.sessionOpen || (await runSessionExists(context));) and reran:

✖ sessionOpen=true, final sessionExists=false: cleanup is never invoked (0.47ms)
  AssertionError [ERR_ASSERTION]: cleanupSession must not run once the session is confirmed gone
  1 !== 0

Case (a) failed exactly as expected — with the old || form, sessionOpen starting true short-circuits the recheck and cleanup still runs against a dead session. Case (b) stayed green (unaffected, since sessionExists already true there). Restored the fix; all 6 tests (4 existing + 2 new) pass again.

Gates at the pushed head, foreground and &&-chained: pnpm typecheck && pnpm lint && pnpm format:check && node --experimental-strip-types --test test/integration/ios-simulator-e2e-cleanup.test.ts — all green. pnpm check:fallow --base origin/main clean.

Generated by Claude Code

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 2, 2026
@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 401e34b. The new finalizeSessionCleanup is a narrow test-harness seam with required callbacks, and finalizeLiveRun still supplies the real session check and cleanup functions while preserving inspection/cleanup error combination and coverage reporting. The dead-session regression now starts with sessionOpen=true, makes the authoritative final check return false, and proves cleanup is not invoked; restoring the old sessionOpen || … short circuit fails that test. The live-session path is pinned too. All exact-head checks are green. The prior 13/13 full-tier run is on parent head 9444dd4; the only later delta is this behavior-preserving seam extraction plus deterministic tests, so the evidence chain is sufficient without another expensive live rerun. No findings remain; ready for human review/merge.

@thymikee
thymikee merged commit e5cebcd into main Aug 2, 2026
27 checks passed
@thymikee
thymikee deleted the claude/sweet-colden-3e66ff branch August 2, 2026 09:36
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-02 09:36 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