Skip to content

test(e2e): retry while a forward heals, and two checks that meant less than they read - #16

Merged
arloliu merged 2 commits into
mainfrom
fix/e2e-forward-heal
Aug 30, 2026
Merged

test(e2e): retry while a forward heals, and two checks that meant less than they read#16
arloliu merged 2 commits into
mainfrom
fix/e2e-forward-heal

Conversation

@arloliu

@arloliu arloliu commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Three repairs to the end-to-end harness, all from evidence gathered this session. No product code changes.

The forward heal budget

One CI run lost console-oidc to this, twenty-five seconds before the forward came back:

09:05:37.043  portforward.go:522  session ended
09:05:37.046  port-forward ended; reopening ports=[35073:8080]
09:06:07.045  error creating forwarding stream for port 35073 -> 8080: Timeout occurred
scenarios_console_test.go:392: GET .../v1/collections/...: read: connection reset by peer

A reopen rebinds the local port before its stream to the Pod works. For the whole window in between the port accepts a connection and resets it, so dialForward's ECONNREFUSED retry never fires, and client-go gives up on a stream it cannot create only after 30 seconds. send retried for dialTimeout — five seconds, sized for one connection attempt — and dialForward for the same five. Both are now forwardHealDeadline, sized by the reopen.

TestSendOutlastsAPortThatResets writes the budget down: a listener that resets every connection for ten seconds and then answers. Against the five-second budget it fails with the CI run's own message, read: connection reset by peer.

The bound this does not cover: 60s is one client-go stream timeout plus slack. harness_test.go loops openForward with pollInterval between attempts, so a dead path to the API server could burn 30s per attempt more than once. A future red run showing port-forward reopen failed more than once means the budget, not the mechanism, is what to revisit.

Repeating a request that carries an idempotency key

send never repeated anything but a GET or a HEAD. It now also repeats a request carrying an Idempotency-Key, which the gateway answers from the receipt it wrote for the first rather than acting twice. No send caller sends a key today — the pgo scenario that exercises a repeated key builds its own request through do, not send — so this changes no current behaviour. It is the mechanism the open POST thread needs, unit-tested by TestRepeatableOnlyWhereTheGatewayCannotTell, not a fix that fires today.

This does not close the open POST thread. The auth-oidc-browser POSTs are login form submissions to the issuer over its NodePort — they never touch the forward, and repeating one could consume an authorization code. They stay unrepeated, correctly.

Two checks that meant less than they read

"<selection> is not listed" renders whenever nsListed is false, which it is while the namespace list is still empty and when the selection is absent from a list that arrived. The scenario checked that text with no evidence of which state it was looking at. It now waits for the namespace select to carry options beyond its placeholder first.

Honest framing: measured across three runs, the list had already landed at the old check point (4 options). This closes a hole; it did not catch a false pass.

refetchCollections returned quietly when the Service control named nothing, so awaitRow's failure blamed a list that never carried a row when the cause could be a page that never named a Service. It now reports whether it asked, and the failure names which happened.

What was checked and found not to be a defect

The handoff this work came from listed two missing returns in app.jsboot() and request() schedule a not_ready retry and then fall through to record the error. docs/specs/ui.md asks for both halves: the errors table gives not_ready the hint "the gateway is still syncing; the page retries every 2 seconds", which only makes sense on an error the page is showing, and the states table says an unsynced gateway leaves the shell loaded and retrying until the first 200. Adding a return would delete the only place a stuck operator learns why. Left alone.

Verification

Full suite against a live cluster: ok github.com/arloliu/profgate/test/e2e 695.394s. mise run check, lint, and prose clean.

A port-forward session the Pod ended is reopened on the same local port.
The reopen rebinds that port before its stream to the Pod works,
so for the whole window in between the port accepts a connection and resets it,
and client-go gives up on a stream it cannot create only after 30 seconds.
Nothing in the suite waited that long.

send retried for dialTimeout, which is five seconds and is sized for one connection attempt,
and dialForward retried a refused dial for the same five.
A scenario therefore failed on a forward that was about to heal:
one CI run lost console-oidc to
"GET https://gateway/v1/collections/...: read: connection reset by peer",
twenty-five seconds before the forward came back.

Both budgets are now forwardHealDeadline, sized by the reopen rather than by a dial.

send also repeats a request that carries an Idempotency-Key, which it never did before.
The gateway answers a repeat of a keyed request from the receipt it wrote for the first,
so the repeat is something it cannot tell apart from a retransmission.
A method with no key is still sent once.
The body is read into memory so a second attempt has one to send.

TestSendOutlastsAPortThatResets is the budget written down:
a listener that resets every connection for ten seconds and then answers.
Against the five-second budget it fails with the CI run's own message.
The console renders "<selection> is not listed" whenever nsListed is false.
That is false while the namespace list is still empty,
and false again when the selection is absent from a list that arrived.
The scenario checked the text with no evidence of which state the page was in,
so it could only ever pass: a page that had fetched nothing showed the same words as the page under test.

The check now waits for the namespace select to carry options beyond its placeholder,
which is the page saying the list landed, and reads the panels again after that.
Measured across three runs, the list had already landed at the old check,
so the wait costs nothing and closes a hole rather than a slowdown.

awaitRow's refetch says whether it asked.
refetchCollections returned quietly when the Service control named nothing,
which left awaitRow's failure blaming a list that never carried a row
when the cause was a page that never named a Service to ask about.
The failure now names which of the two happened.
@arloliu
arloliu force-pushed the fix/e2e-forward-heal branch from 546f64d to 790c8a9 Compare August 30, 2026 13:11
@arloliu
arloliu merged commit bc42777 into main Aug 30, 2026
5 checks passed
@arloliu
arloliu deleted the fix/e2e-forward-heal branch August 30, 2026 13:11
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