Skip to content

test(e2e): attribute native-config-networks probe timeouts to a URL, phase and route state - #470

Merged
roodboi merged 1 commit into
nextfrom
wf/g84-config-networks-ingress-exit27
Oct 24, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g84-config-networks-ingress-exit27

Conversation

@roodboi

@roodboi roodboi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs HACK-1419

Summary

This PR does not fix a root cause. The hosted evidence cannot tell a readiness race from a real routing defect, so it is not safe to change product code or relax the assertion. What it does is make the next failure conclusive: the log will name the URL, the phase, and the route state.

What failed

On #463, run 40057262783, job 120762873007 (docker-e2e shard 1/4), native-config-networks failed at the mixed routed ingress check:

Network command diagnostic {"step":"hack:exec","role":"primary","sequence":925,"expected":0,"exit":27,"timedOut":false,"durationMs":12872,...}
FAIL native-config-networks (1391.6s) — Fixture command must exit 0; captures retained
  • What exit 27 means. It is not curl's code 27. It is the fixture's own NATIVE_NETWORK_PROBE_EXIT.timeout: the in-container peer probe (bun -e, run through hack exec <service>) hit its 4 s AbortSignal.timeout while fetching http://<target>:3000 or http://<target>-alias:3000/.
  • Where it failed. The failure came 70 s after "checking mixed routed ingress". At that point the census shows all 4 primary services running, plus the proxy. So the failing exec is in markers(primary) right after the routed hack up. In that phase web is attached to the external hack-dev ingress as well as its authored outbound bridge.
  • Not a service-readiness race. observe(primary) runs just before markers, and it requires every container to be healthy. The healthcheck is an in-container fetch of 127.0.0.1:3000, so every server was already listening. A server that was not listening yet would refuse the connection and exit 26 at once, not stall until timeout.
  • What the log cannot show. It does not say which of the four service pairs failed, which URL (origin or alias), or whether name resolution or the HTTP exchange stalled. Hosted captures stay on the runner, and the run uploaded no artifact.

Frequency

I checked the docker-e2e shard jobs of the last 60 ci.yml runs on next and PR branches: 228 jobs, 215 green, 10 in progress, 3 failed. The 3 failures:

  • This one: exit 27 at the routed ingress check. It is the only occurrence.
  • Run 39916341281 (next), shard 1: a different native-config-networks failure, sibling-b hack exec with E_COMPOSE_FAILED storage-helper-start/probe-timeout. That is the storage helper budget, not ingress.
  • Run 40055366376 (next), shard 4: native-compose-adoption-job-worktrees, which is unrelated.

So the exit 27 has appeared once in about 51 shard-1 runs.

Candidate causes (none confirmed)

  1. Name resolution stalled. A lost UDP reply from Docker's embedded DNS (127.0.0.11 is DNAT'd in the container netns) means the resolver waits about 5 s before retrying. That is longer than the probe's 4 s budget.
  2. A misroute specific to multi-homed web. Here the name answers an address that is not on a bridge the two containers share.
  3. A transient connect stall on a fresh attachment.

Each of these produces exit 27 today, and they cannot be told apart from the log.

Change (test fixture only)

  • nativeNetworkFixturePeerProbe resolves first, then fetches. It resolves the name (Bun.dns.lookup, IPv4 preferred) and then fetches the resolved address. Resolution and the HTTP exchange share the same 4 s per-URL budget (NATIVE_NETWORK_PEER_PROBE_BUDGET_MS), so the assertion is not loosened. Resolution failures get their own exit codes, dnsRejected 30 and dnsTimeout 31. Codes 26 and 27 now mean the HTTP phase only.
  • The probe writes one closed stderr line before a failing exit: probe-evidence <origin|alias> <dns|http> <dnsMs> <elapsedMs> <addressCount>. It never includes an address or body.
  • On a failed peer probe the scenario logs Network probe diagnostic {...}. The line contains:
    • the role, the service and target (fixed fixture names), the exit code, outcome and parsed evidence;
    • the route state, reduced to counts and booleans: both containers' state and health, attachment counts, shared bridges, whether the source is on the ingress, and how many addresses getent ahostsv4 <probed name> returned inside the source container, plus whether every one is the target's address on a shared bridge;
    • the outcome of one diagnostic-only rerun, which shows whether the stall is transient or persistent. The step still fails either way.
  • Failures in the diagnostic path cannot hide the original failure. Any error there logs Network probe diagnostic unavailable.

How to read the next occurrence

  • phase:"dns" with a passing rerun: a resolver stall. Consider a resolver-aware budget.
  • resolvesToSharedTarget:false: a real routing or DNS defect. Fix it in the product, with a unit test.
  • phase:"http", a shared target address, and a failing rerun: a persistent connectivity defect.

Validation

  • Unit tests: bun test tests/native-networks-fixture.test.ts gives 63 pass. There are 4 new tests: probe phase and URL attribution, the evidence parser, route-state reduction, and the closed probe diagnostic. The existing probe exit-code test still passes.
  • Mutation proof: each of these 8 mutations kills the tests: DNS phase mislabelled, wrong DNS rejection code, IPv4 preference dropped, budget not shared, route every changed to some, evidence regex unanchored, rerun dropped, ingress ignored.
  • Gates: bun run check (includes privacy:check) passes. bun run --cwd packages/cli typecheck and bun run typecheck report 0 errors. Biome is clean on both changed files. gitleaks 8.30.1 finds no leaks over origin/next..HEAD.
  • Docker: nothing ran against a local Docker socket. Hosted docker-e2e shard 1 runs native-config-networks and exercises the new probe in real containers.

Gaps

  • Root cause still unknown. This PR closes only the diagnosis gap. HACK-1419's acceptance ("cause identified") needs the next occurrence, which will now be conclusive, or a deliberate reproduction on an isolated Docker host.
  • The diagnostic path has not run against real Docker. It runs only when a probe fails, and its hosted coverage is limited to the success path. Its pure helpers are unit-tested.

Hosted CI at 3e88132

  • Docker e2e: all four docker-e2e shards pass, as does the docker-e2e aggregate. In shard 1 (run 40060640525, job 120773164774), PASS native-config-networks (2288.2s), including the mixed routed ingress phase, with the new probe running in real containers.
  • Other checks: test, secret-scan, linux-process-lifetime, runtime-images, Runtime state models, and Config compiler (ubuntu, arm, macos-latest) pass. Candidate core and Config compiler on macOS were still running when this was written.
  • Runtime: the scenario took 2288 s here, against about 1290–1330 s on recent green runs. That runner was slow before the first probe ever ran: the probe-free created-before-start phase took 44 s against 15 s, and bun install took 47 s against 36 s. I attribute the difference to the runner, not the probe, but one run cannot rule out a small per-probe cost.

🤖 Generated with Claude Code

…phase and route state

The mixed routed ingress check failed once in hosted CI (HACK-1419) with
`hack exec` exit 27: the in-container peer probe's 4 s fetch timeout. The
log could not say which service pair, which URL (origin or alias) or which
phase (name resolution or HTTP) stalled, and no captures leave the runner.

The peer probe now resolves the name first and fetches the resolved
address, under one shared per-URL budget (unchanged 4 s), and exits with
distinct dnsRejected/dnsTimeout codes for the resolution phase. Before a
failing exit it writes one closed stderr line: URL label, phase, resolution
and total milliseconds, and the resolved address count.

On a failed peer probe the scenario logs a value-free probe diagnostic:
role, service and target, the closed evidence, the source container's
getent answer reduced to an address count and whether every address is the
target on a shared bridge, both containers' states and health, attachment
counts and ingress attachment, and the outcome of one diagnostic-only rerun.
The step still fails; the rerun only classifies transient versus persistent.

Refs HACK-1419

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

linear-code Bot commented Oct 24, 2026

Copy link
Copy Markdown

HACK-1419

@roodboi
roodboi marked this pull request as ready for review October 24, 2026 14:38
@roodboi
roodboi merged commit e08f971 into next Oct 24, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g84-config-networks-ingress-exit27 branch October 24, 2026 14:38
roodboi added a commit that referenced this pull request Oct 24, 2026
…K-1428) (#484)

Follow-ups after #470 (e08f971). Test and fixture-harness changes only; no product code
changes.

## Root cause

1. **Shared-budget subtraction untested.** Local DNS resolution of `127.0.0.1` or `localhost`
takes about 0 ms, and the hung-probe bound (< 2000 ms for a 300 ms budget) was loose. So
`Math.max(1,${timeout}-d)` → `${timeout}` still passed. 2. **Resolved-address fetch untested.**
Every probe test used names that the system resolver also resolves to the listener. A fetch to
the original hostname reached the same server, so removing `target.hostname = …x.address` still
passed. 3. **Create shim oracle flake (`Expected: 71, Received: 128`).** The test makes nine
shim invocations in sequence, and each one starts one or two Bun processes. On a loaded host
this took longer than bun test's 5 s default timeout. When the timeout fires, bun test SIGTERMs
the still-running shim ("killed 1 dangling process"). The runner mapped the signaled exit (`code
=== null`) to a bare `128`. The timed-out test then keeps running while later tests run, and its
`expect(…).toBe(71)` fails. Bun reports that as "Unhandled error between tests", so the visible
failure is `Expected 71 / Received 128` and not the timeout. A minimal repro outside the repo
showed the same thing: a test whose child outlives the timeout gets `exit code=null
signal=SIGTERM`, then `Expected: 71 Received: 128` as an unhandled error between tests. The same
mechanism also hit `probe scripts exit with a closed code…` (`Received: 143`, from `Bun.spawn`'s
128+15).

## Fix

- `tests/native-networks-fixture.test.ts`
  - New `peer probe fetches the address it just resolved`: replaces `Bun.dns.lookup` in the
  probe child with a stub that resolves RFC 6761 `.invalid` names to 127.0.0.1. The probe must
  pass, and the server must see `Host: 127.0.0.1:<port>` on both requests. A second, real
  resolution of a `.invalid` name cannot succeed.
  - New `peer probe charges resolution time against the shared per-URL budget`: the stub delays
  resolution by 1200 ms with a 2000 ms budget against a hung endpoint. It asserts `dnsMs ≥
  1150`, `elapsedMs ≥ 1950`, and `elapsedMs < 2600` (a fresh full budget ends near 3200). It
  also checks that a 500 ms budget with a 1200 ms resolution exits `dnsTimeout` (31) with
  `phase: dns` at 450–1200 ms.
  - New `capture reports a signaled command as 128 plus its signal number` (SIGTERM → 143,
  SIGKILL → 137).
  - `setDefaultTimeout(60_000)` for this file, following `tests/native-compose-
  ownership.test.ts`. Every fixture command and probe keeps its own, tighter timeout.
- `tests/e2e/native-config-networks-acceptance.ts`: `runNativeNetworkFixtureCommand` reports a
  signaled command as `128 + signal number`, as a shell does, so a future kill names its signal.
  The timeout and oversize paths still throw before any exit code is returned. Nothing compared
  the exit code with `128`.

## Evidence (local, macOS, Bun 1.4.2; host load average 45–110 from other work)

Mutations (each reverted afterwards):

| Mutation | Before this PR (`-t probe`) | With this PR | |---|---|---| |
`Math.max(1,${timeout}-d)` → `${timeout}` | 5/5 pass | budget test fails: `Expected: < 2600,
Received: 3211` | | drop only `target.hostname=…x.address;` (keep `let ok=false;`) | 5/5 pass |
address test fails (`code 26`, not 0); budget test fails (`Expected 27, Received 26`) | | runner
`code ?? 128` (signal ignored) | n/a | signal test fails: `Expected: 143, Received: 128` |

Flake loops (`bun test tests/native-networks-fixture.test.ts`, private
`HOME`/`HACK_HOME`/`DOCKER_CONFIG`, `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, no Docker):

| Run | Before fix | After fix | |---|---|---| | shim test alone (`-t "strict argv oracle"`), 40
runs | 21 pass / 19 fail, all at the 5 s timeout | 39 pass / 1 fail (the failure was not this
test; see gaps) | | whole file, 10 / 30 runs | 8 / 10: both failures were the shim test timing
out with `Expected: 98, Received: 128` plus the closed-code probe test with `Received: 143` | 30
/ 30 pass |

Gates: `bun run check` (includes `privacy:check`) passes, `bun run --cwd packages/cli typecheck`
reports 0 errors (it includes `tests/`), commitlint passes, and gitleaks 8.30.1 finds no leaks
over `origin/next..HEAD`. The CLI surface and agent guidance did not change. Hosted CI at the PR
head is the full-suite evidence.

## Gaps

- Hosted `test` and docker-e2e shards at this head are the full-suite proof; Docker e2e was not
  run locally.
- In 1 of the 40 post-fix runs of the single test, the shim test itself passed, but the checkout
  write guard's `afterAll` `git status` timed out at 5 s under load (`tests/helpers/checkout-
  write-guard.ts`). That is outside this unit and is reported separately.
- The IPv6 branch of the address rewrite (`[${x.address}]`) still has no test.
- Stopping a loop mid-run (killing its `bun test`) left one Bun `SIGABRT` crash report from an
  orphaned child. The loops that ran to completion added none.

Refs HACK-1428

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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