Fix workload status poisoning and deflake Envoy AllowPort - #6076
Merged
Conversation
JAORMX
requested review from
ChrisJBurns,
amirejaz,
aponcedeleonch,
jhrozek,
rdimitrov and
reyortiz3
as code owners
July 28, 2026 07:44
ListWorkloads snapshotted the runtime before the status files. A workload whose file flips to running between the two snapshots — every thv run does this, since the detached child starts the container and then writes running — appears as "file running, runtime missing", and handleRuntimeMissing persists unhealthy into its status file. Nothing recovers a non-running file status against a live container, so one concurrent `thv list` permanently marks a healthy, freshly started workload unhealthy. This is user-facing state corruption; it also caused the E2E Tests Core (vmcp) failure on main (run 30335474873), where a backend that had demonstrably started and stayed alive was reported "not found in runtime" for two minutes. Read the files before the runtime: a file that says running at time T implies its container existed before T, so a later runtime miss is genuine. The old order was introduced without stated rationale in e6e29dc. Guard the unhealthy write itself: re-check under the file lock and skip when the file is gone or no longer says running/stopped. Reordering alone would let a list that brackets a whole `thv rm` recreate the just-deleted status file as an unhealthy ghost, because SetWorkloadStatus creates missing files. Give waitForIsolatedMCPServer a state dump through its isolated env on timeout; the upgrade-spec failure on the same run was undiagnosable because the generic timeout message carries no status context. Refs #4432 — the same write-on-read class; this fixes the startup trigger but thv list still writes on read, so that issue stays open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam
The AllowPort spec fetched https://example.com exactly once, immediately after workload readiness. The allowed leg needs a working DNS -> TLS -> upstream chain through Envoy's dynamic_forward_proxy, and the first egress request after startup can race the isolation stack's own warm-up, failing instantly with a local error before anything reaches the network. Observed twice on 2026-07-28 (PR heads 1588e6a and 5c901a9, within the same minute): IsError ~40ms and ~190ms after readiness — far too fast for genuine external TLS trouble — while the same spec passed on main minutes later. The property this spec pins is steady-state reachability of an allowed port; first-request-after-boot success is not something the DFP path promises. Retry the allowed leg until the deadline, and surface the fetch tool's error text on failure — the diagnostic that distinguishes a warm-up race from a genuinely broken AllowPort, which the old boolean-only assertion discarded. A broken AllowPort still fails, now with evidence. The blocked port-80 leg is unchanged: denial is enforced locally and deterministically once the stack is warm, which the preceding allowed leg has just proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam
JAORMX
force-pushed
the
fix-thv-list-status-poisoning
branch
from
July 28, 2026 07:52
5b755b6 to
89ceb64
Compare
8 tasks
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6076 +/- ##
==========================================
- Coverage 72.26% 72.25% -0.02%
==========================================
Files 725 725
Lines 75358 75376 +18
==========================================
+ Hits 54460 54462 +2
- Misses 17016 17029 +13
- Partials 3882 3885 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jhrozek
approved these changes
Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent fixes for the e2e shard failures on
mainaround ca4eda4, combined into one PR at the maintainer's request. They are separated below (and as two commits) so each can be evaluated on its own; neither is a regression fix — #6050/#6051/#6061 were ruled out on evidence (the failing vmcp spec died beforethv vmcp serveever launched).1.
thv liststatus poisoning (product bug;pkg/workloads/statuses)thv listduring any workload start can permanently persistunhealthyinto a healthy workload's status file — and nothing recovers it. This is user-facing state corruption, not a test problem. TheE2E Tests Core (vmcp)failure onmain(run 30335474873, commit ca4eda4) is the symptom that exposed it: a backend workload that had demonstrably started and stayed alive was reportedunhealthy (workload not found in runtime)for two full minutes.Mechanism:
ListWorkloadssnapshotted the runtime before the status files. Everythv runflips the file torunningmoments after the container is created, so a list whose runtime snapshot predates the container while its file read postdates therunningwrite sees "file running, runtime missing".handleRuntimeMissingthen persistsunhealthyinto the status file.validateWorkloadInListskips runtime validation for every non-running file status, so anunhealthyfile entry with a perfectly healthy container staysunhealthyforever.What changed:
runningat time T implies its container existed before T, so a later runtime miss is genuine. The old order was introduced without stated rationale in e6e29dc (Fix bugs found while testing file-based state tracking #1347);GetWorkloadwas already file-first, so the two paths are now consistent.setUnhealthyIfStillPresent): re-check under the file lock and skip if the file is gone or no longer says running/stopped. The reorder alone would open a narrow mirror hazard — a slowthv listbracketing an entirethv rmcould recreate the just-deleted status file as an unhealthy ghost, becauseSetWorkloadStatuscreates missing files. The guard closes that and stops a stale verdict from clobbering a fresher status such asremoving.waitForIsolatedMCPServer(upgrade e2e) now dumps state through its isolated env on timeout. Thecoreshard failure on the same run was this helper timing out with a bare boolean — no status context, no logs — which made that failure undiagnosable. (e2e.DebugServerStatecan't be reused here: it queries the real ToolHive config, not the spec's isolated env.)Refs #4432 — that issue documents the same write-on-read corruption from a different trigger (multi-runtime). This PR fixes the startup trigger and hardens the write, but
thv liststill writes on read, so #4432's root cause (and its "read-only list path" direction) remains open. DeliberatelyRefs, notCloses.Proven vs inferred: the vmcp flake is proven end-to-end (the workload's own container/proxy logs show a clean, live start while
thv listreported the persisted "not found in runtime" context, and the race is reproduced deterministically in a unit test). The upgrade-spec flake on the same run is inferred from the shared code path — it pollsthv listevery second during workload recreation — but its log had no state dump, which is exactly what the diagnostics change fixes for next time.2. Envoy AllowPort e2e deflake (
test/e2e/network_isolation_envoy_test.go)E2E Tests Core (network-isolation)failed on the pre-merge heads of both #6050 (1588e6a4) and #6051 (5c901a9d) — the latter containing zero production code — and passed onmainminutes later. Both failures were the same spec, same assertion, within the same minute: the AllowPort spec's allowed leg,https://example.com must be allowed (port 443 is in AllowPort).Root cause: the allowed leg fetched
https://example.comexactly once, immediately after workload readiness. It needs a working DNS → TLS → upstream chain through Envoy'sdynamic_forward_proxy, and the first egress request after startup can race the isolation stack's own warm-up (DFP DNS cache, the per-workload DNS container), failing instantly with a local error. The observed failures returned ~40ms and ~190ms after readiness — far too fast for genuine external TLS trouble — and the boolean-only assertion discarded the fetch tool's error text, so the log could not say why.What changed: the allowed leg retries via
Eventually(60s budget, 2s poll) and records the last fetch error text, which is printed on failure.Why this is fixing an incidental assertion, not weakening a real one:
AllowPortend-to-end) is steady-state reachability of an allowed port. First-request-after-boot success is not something Envoy's DFP path promises, so a single-shot first fetch asserts warm-up behaviour the product never guaranteed.AllowPortstill fails — after the retry budget, and now with the fetch error text surfaced, which is the diagnostic that distinguishes a warm-up race from a real enforcement break on the next occurrence.No timeout was raised; the change addresses the mechanism (first-request warm-up) and improves the failure's diagnosability. The warm-up diagnosis is the best fit for the evidence but could not be pinned to the exact Envoy error because the old assertion discarded the error text; if this spec ever fails again, the surfaced text settles it.
Type of change
Test plan
task test)task lint;go vet ./...run separately)The statuses regression tests were mutation-verified against the code they pin:
TestFileStatusManager_ListWorkloads_DoesNotPoisonWorkloadStartingDuringListflips the status file torunningfrom inside the mocked runtime call. Fails on the old snapshot order (persisted status corruptedrunning→unhealthy, reproducing the CI poisoning deterministically) and passes with the reorder.TestFileStatusManager_ListWorkloads_UnhealthyMarkIsGuardeddeletes the file / flips it toremovingfrom inside the runtime call. Both subtests fail with the guard reverted to a plainSetWorkloadStatus(ghost file recreated;removingclobbered) and pass with the guard.task testshows only the three known environment-dependent failures (TestNewServer_ReadTimeoutConfigured,TestSecurityHeaders,TestCompositeProvider_RealProviders). Not verified by execution: the e2e suites themselves (no container runtime in the dev environment) — the Envoy retry's effect on the flake is only observable in CI; it is verified by compilation, lint, and by checking the failure-time evidence against the retry semantics.Does this introduce a user-facing change?
Yes.
thv listno longer intermittently (and permanently) marks a workloadunhealthywhen it happens to run while that workload is starting; and athv listracingthv rmno longer resurrects the removed workload's status file as a ghost entry.Special notes for reviewers
handleRuntimeMismatchandisProxyUnhealthystill write unconditionally. They only fire when the container is present in the snapshot, so the reorder does not change their exposure; migrating them to the guarded write would be a reasonable follow-up.Generated with Claude Code