Skip to content

fix(e2e): tolerate pattern fixture containers removed before inspection - #386

Merged
roodboi merged 1 commit into
nextfrom
wf/g21-p01-removed-container-b19
Oct 22, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g21-p01-removed-container-b19

Conversation

@roodboi

@roodboi roodboi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

The P01 scenario failed on CI (PR #341, run 39435455774, job 118671149247, docker-e2e shard 3/4) at the start of the killed-installer step: docker inspect 307b0e394010 returned [] and Error: No such object (exit 1).

The timeline in the job log shows what happened. The lock-change step logged success at 14:03:28.54. The failure diagnostics then ran at 14:03:28.80. So the failure came from the first waitFor poll after the step started hack up --json in the background with a new install command and a new hack.dependencies.cache-generation label.

The poll calls ownedContainers, which runs docker ps -aq and then docker inspect as two separate calls. The ID it listed was the linked worktree's exited install container from the lock-change step. The concurrent hack up changed that service's configuration, so Compose removed the container and created a new one between the two calls. The strict checkedDocker inspect then failed the scenario. The diagnostics that followed show a fresh linked install container with exit 0 and no logs, which is the new container Compose had just created.

The problem was in the test helper's list-then-inspect step. The dependency-cache behavior itself was not at fault.

Fix

tests/e2e/scenarios/pattern-fixture.ts:

  • A new pure function, partitionContainerInspection, splits a docker inspect --type container <listed ids> result into the inspected containers and the listed IDs that no longer exist. It accepts a missing ID only when all of these hold:

    • stdout is a JSON array;
    • every stderr line is a No such object or No such container line for an ID it listed (the wording differs between client versions);
    • each listed ID is either inspected or reported missing, never both and never neither;
    • the exit code matches: 0 only when no ID is missing.

    Any other result is an error: a timeout, output that is not JSON, a daemon or permission error, a missing ID it did not list, or an ID that is silently dropped.

  • ownedContainers inspects with --type container. For each missing ID, it runs docker ps -aq --filter id=<id> to confirm the container is really gone, then leaves it out of the result and logs that it did so. Any other inspection failure still fails the scenario.

What stays guaranteed

The scenario's assertions are unchanged. The killed attempt's volume must still be populated but have no ready marker. No running consumer may mount it. A retry of the same generation must still be refused and must address the same volume. A generation bump must still recover with a fresh payload. only() still requires exactly one owned container, and the waitFor poll retries until the running installer has started writing.

Tests

  • tests/e2e/pattern-fixture.test.ts (new) covers:

    • the exact CI failure shape;
    • partial removal, with both stderr wordings;
    • eight rejection cases (daemon error, an ID that was not listed, an extra stderr line, an ID that is silently dropped, an ID that is both inspected and reported missing, exit 0 with a missing ID, a timeout, output that is not an array).
  • Each test was checked by mutating the code under test:

    Mutation Result
    Restore "any non-zero exit fails" 2 tests fail
    Accept any stderr line rejection test fails
    Drop the exit/missing agreement check rejection test fails
    Drop the inspected-or-reported check rejection test fails

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2). Docker client and server 29.8.2 (OrbStack, arm64).
  • Real-engine reproduction of the race. A docker shim on PATH removed one of two owned test containers just before docker inspect, then the real ownedContainers ran against the real daemon:
    • Old helper (next): fails with docker inspect pattern fixture operation (expected exit 0, got 1). This is the CI error.
    • Fixed helper: returns the surviving container and logs owned container(s) <id> were removed between listing and inspection; confirmed absent.
    • Cleanup: zero leftover owned containers.
  • The real client's stderr for a missing ID is Error response from daemon: No such container: <id> with --type container, and error: no such object: <id> without it. The tests cover both.
  • bun run --cwd packages/cli typecheck: clean. bun run --cwd packages/cli check: 0 errors (34 existing warnings). gitleaks 8.30.1 over the branch: no leaks.
  • Full bun run test on a heavily loaded shared host (load average 40–145) had 795 failures across 119 files.
    • On a serial rerun with --timeout 60000, 104 of those 119 files passed.
    • 15 native-compose and native-authored files still fail. They do not import the changed module. Only the e2e scenarios and the new unit test import tests/e2e/scenarios/pattern-fixture.ts.
    • The pattern-fixture and pattern-examples tests pass.

Remaining gaps

  • I could not run the full P01 scenario locally. The run passed the cold and warm steps, then the linked-worktree hack up failed with all predefined address pools have been fully subnetted. The local daemon's networks belong to other projects and agents, and I did not prune any of them. Hosted docker-e2e shard 3 on this head is the end-to-end proof that is still needed.
  • The 15 local unit-test files that still fail need hosted CI on this head to confirm they are unrelated.

Refs HACK-1296

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

The P01 killed-installer step polls owned install containers while a
concurrent `hack up` recreates the linked `install` service. Compose
removed the listed container between `docker ps -aq` and
`docker inspect`, so the helper failed with "No such object".

ownedContainers now inspects with `--type container` and accepts only a
listed ID that Docker reports missing and `docker ps` confirms absent;
any other inspection failure still fails the scenario.

Refs HACK-1296

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

HACK-1296

@roodboi
roodboi marked this pull request as ready for review October 22, 2026 08:11
@roodboi
roodboi merged commit 04d7a09 into next Oct 22, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g21-p01-removed-container-b19 branch October 22, 2026 08:11
roodboi added a commit that referenced this pull request Oct 30, 2026
…#528)

Fixes HACK-1346 for the e2e harness on `main`.

## What I found

- **The happy path doesn't leak.** I ran all five main Docker scenarios
(`cache-prune`, `dependency-cache`, `up-down`, `lifecycle-host-process`,
`worktree-parallel-up`). The host had 19 networks before the run and 19
after.
- **Killed runs leak.** I sent SIGINT to the runner during
`worktree-parallel-up`. It left 2 networks and 4 containers: `e2e-<hex>`
and `e2e-<hex>--e2e-parallel`. Nothing runs a `finally` when the process
is killed.
- **The fallback sweep used the wrong branch name.** `downBestEffort`
ran `docker compose -p <name>-<branch> down`, but the CLI names branch
instances `<name>--<branch>` (`resolveLifecycleComposeProjectName`). So
if `hack down --branch` failed, the fallback missed the branch instance.
- **Nothing failed on a leak.** When a scenario skipped cleanup, the run
still passed and the network stayed behind.
- **Address pool.** This host's Docker allows 30 IPv4 `/24` pools.
Non-e2e Compose projects already use 16 of them, so the 33–34 networks
in the issue means the pool was full. P01 needs two networks: primary
plus linked worktree.
- **P01 isn't on `main`.** `pattern-p01-dependency-bootstrap` and
`pattern-fixture.ts` exist only on `next` (#336, #386). Their fixtures
get names from `randomProjectName()`, so this PR records ownership there
too. Once `next` lands, those scenarios get the leak check and sweep
with no further change.

## Change

- New `tests/e2e/docker-ownership.ts`:
- Every fixture name this process generates is recorded as owned, in
memory and in a per-PID journal under `$TMPDIR/hack-e2e-ownership/`
(override with `HACK_E2E_OWNERSHIP_DIR`).
- Cleanup only touches Compose containers, networks and volumes whose
`com.docker.compose.project` label is an owned name or
`<owned>--<branch>`. It removes them by exact ID and never prunes.
  - It never touches other concurrent e2e runs or unrelated projects.
- `runScenarios`: after each Docker scenario, any owned network still
present **fails the scenario**. The harness then removes that scenario's
owned resources and checks the networks are gone.
- `run.ts`:
- Prints `fixture networks: before=N after=M left by this run=K` and
exits 1 if K > 0.
- On SIGINT/SIGTERM/SIGHUP, removes the run's owned resources before
exiting.
- At start, reclaims journals left by dead runs (for example after
SIGKILL). It skips journals whose process is still alive.
- `downBestEffort` now uses `<name>--<branch>`.
- README "Cleanup guarantees" is updated.

## Proof (real Docker on the shared m5 host, `./dist/hack`)

| Case | main's harness | this PR |
|---|---|---|
| SIGINT during `worktree-parallel-up` | exit 130; **2 networks + 4
containers left** | exit 130; handler removed 4 containers + 2 networks;
0 left |
| Mutation: `up-down` skips `hack down` and `downBestEffort` | **exit 0,
PASS**, network `e2e-19c60e0e` left | **exit 1, FAIL** "scenario left 1
Docker network(s)…"; sweep removed it |
| SIGKILL during `worktree-parallel-up` | n/a | 2 networks + 4
containers left (expected); the next run printed `reclaimed from a dead
e2e run: …` and removed all of them |
| Full suite (`HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1`) | — | 14/14
pass, `fixture networks: before=0 after=0 left by this run=0` |

Unit tests: `tests/e2e-docker-ownership.test.ts` (7 tests) use a fake
Docker runner. They cover:
- ownership boundaries (`e2e-abc1` does not own `e2e-abc12--x`);
- that only owned resources are removed, containers then networks then
volumes, with no prune;
- the leak check, including through `runScenarios`;
- journal recovery: dead PID swept, live PID left alone;
- `branchComposeProject` matches the CLI's naming.

Mutation check: with the leak check disabled, and with branch naming
reverted to main's single dash, 2 of the 7 tests fail. All 7 pass with
the fix.

Gates: `bunx turbo run typecheck check test --force` passes (6/6 tasks,
968 tests, 0 fail). Without `--force`, turbo replays cached results
because root `tests/` is outside the package inputs. `bunx ultracite
check tests/e2e tests/e2e-docker-ownership.test.ts` is clean.

## P01 `runningConsumersComplete` race (on `next`): not confirmed

It lists running `app` containers and then runs `docker exec` on each,
so the code does check then act. The only overlapping operation is the
first call, during the in-flight linked `hack up`. There, `app` depends
on `install` (`service_completed_successfully`), and Compose waits on
that dependency before it recreates `app`. The installer is killed, so
`up` fails before any `app` container is touched. Every later call runs
after `up` has settled. I didn't reproduce it, and I didn't run it here
because P01 isn't on `main`. Leaving it with HACK-1296.

## Release signal

Only test and harness code changes; no CLI behaviour changes, so no
release.

@codex review

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/hack-dance/codesmith/hack/pr/528?autoLogin=true&ref=codesmith_pr_footer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v3.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png"
width="162" height="28"></picture></a> <a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1795988207&installation_model_id=17053&pr_number=528&ref=codesmith_pr_footer&repository=hack-dance%2Fhack&return_to=https%3A%2F%2Fgithub.com%2Fhack-dance%2Fhack%2Fpull%2F528&signature=ff90fcdabe6ff8b227747e4adee3f19aafed196418fc5c4e151c1affdb97fb40"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png"
width="190" height="28"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

---------

Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant