Skip to content

Reliability hardening: interrupted-run semantics, race-clean CI, sandbox and persistence fixes#27

Merged
alxxjohn merged 14 commits into
mainfrom
improve
Jul 2, 2026
Merged

Reliability hardening: interrupted-run semantics, race-clean CI, sandbox and persistence fixes#27
alxxjohn merged 14 commits into
mainfrom
improve

Conversation

@alxxjohn

@alxxjohn alxxjohn commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

A reliability audit of the codebase (runner/engines, network adapters, integrations, persistence, CLI, and MCP server) surfaced seven high-severity defects. This PR fixes the top six items from the audit, each verified with new regression tests. The full suite now passes go test -race -shuffle=on ./... (423 tests) via cleanr-dev check.

Fixes

1. Interrupted runs reported Passed: true (fix(runner))

A run cut short by SIGINT/SIGTERM or -timeout computed Passed from only the suites that happened to execute — a CI job killed early could exit 0, append a passing entry to trend history, and get a signed release-gate attestation with its security suites never having run.

  • Report now records Interrupted and SkippedSuites; Passed is forced false on truncation.
  • Engines no longer emit phantom zero-value failed cases when cancelled mid-suite (runBoundedByIndex returns the executed prefix).
  • cleanr run exits 130 for interrupted runs (documented in docs/ci.md), skips trend-history persistence, and refuses to sign an attestation. The text report shows Status INTERRUPTED with the skipped suites.

2. Data race + no race detector anywhere (test)

tests/cli/snapshot_test.go swapped the global http.DefaultTransport under t.Parallel(), racing every parallel test doing HTTP — invisible because neither CI, make test, nor cleanr-dev test ever ran -race. The swap test is now sequential, the CLI-adapter helper timeout is raised for race-instrumented startup, and both CI and cleanr-dev test run -race -shuffle=on.

3. WASM plugins could hang the run forever (fix(plugins))

The wazero runtime lacked WithCloseOnContextDone(true), so guest code ignored context deadlines — a plugin with an infinite loop wedged Runner.Run permanently, defeating the sandbox. The entry timeout now interrupts guest execution and is scoped to execution only (module compilation is host-side work). A regression test proves a for {} guest is killed.

4. MCP server crash + missing HTTP timeouts (fix(mcpserver), fix(integrations))

  • cleanr_generate_dataset passed a typed-nil *http.Client into the real generation path → nil-deref panic that killed the whole stdio server (no recover() anywhere). It now gets a real client, and tool dispatch is wrapped in recover() so a panicking handler returns a JSON-RPC error while the server keeps answering.
  • Generic sink POSTs and trend-source fetches built clients with Timeout: 0 when timeout_ms was unset — one hung endpoint stalled a release-gate run indefinitely. All integration HTTP calls now share one constructor with a 10s default.

5. Credential-egress bypass in PostHog/Langfuse sinks (fix(integrations))

Both sinks send credentials outside applyAuth (request body / Basic auth), skipping the CredentialEgressAllowed policy — a config with project_token_env: OPENAI_API_KEY and an attacker base_url could exfiltrate a provider secret. Both now enforce the same egress policy and fail loudly.

6. Persistence and config-defaults hardening (fix(persistence), fix(config))

  • New cleanr/fsatomic package (temp file + fsync + rename + dir sync) replaces two copy-pasted helpers and is adopted by the previously non-atomic writers: cleanr.yaml, the credentials-bearing profile.json, attestations, and replay artifacts. An interrupted write can no longer destroy or corrupt any of them.
  • Zero-ambiguous config fields are now pointer-typed with defaulting *Value() accessors (following the MaxFailedCasesDelta precedent): require_review: false is expressible (was silently inverted to true), max_normalized_drift: 0 means zero tolerance (was replaced by 0.3), min_score: 0 survives, and trend-gate presets no longer override an explicit enabled:exploratory + enabled: true keeps relaxed thresholds with gating active. Since accessors default at read time, SDK-built configs that bypass applyDefaults get correct thresholds too.

Behavior changes to be aware of

  • New exit code 130 from cleanr run when interrupted (previously 0 or 1 depending on partial results). Pipelines treating non-zero as failure are unaffected; pipelines matching exit codes exactly should add 130.
  • Interrupted runs are no longer written to trend history or attested.
  • JSON/YAML reports gain optional interrupted and skipped_suites fields.
  • TrendGateConfig.Enabled, ScenarioGenerationConfig.RequireReview, LLMJudgeConfig.MinScore, and six DriftConfig thresholds are now pointer-typed in the Go SDK (read via *Value() accessors). YAML/JSON configs are unaffected.
  • Misconfigured PostHog/Langfuse sinks pointing provider secrets at untrusted hosts now fail to publish instead of leaking.

Test plan

  • cleanr-dev check (gofiles layout, fmt, vet, go test -race -shuffle=on ./...) — 423 passed, 0 failed
  • New regression tests: interrupted-run semantics, hanging-WASM-guest kill, MCP tool-panic containment, sink egress refusal, explicit-zero/false config round-trips, exploratory-preset override
  • go vet ./... clean

🤖 Generated with Claude Code

alxxjohn and others added 12 commits July 2, 2026 13:41
A run cut short by SIGINT/SIGTERM or -timeout previously computed Passed
from only the suites that happened to execute, so a truncated run could
exit 0, append a passing entry to trend history, and be signed by the
release-gate attestation with its security suites never having run.

- Report records Interrupted and SkippedSuites; Passed is forced false
  when the run did not complete.
- runBoundedByIndex returns the executed prefix so engines truncate
  pre-sized case slices instead of reporting phantom zero-value failures.
- cleanr run exits 130 for interrupted runs, skips trend-history
  persistence, and refuses to build an attestation.
- Text report shows INTERRUPTED status and the skipped suite names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wazero ignores context cancellation during guest execution unless the
runtime is built with WithCloseOnContextDone, so a plugin with an
infinite loop blew straight through the 5s entry timeout and hung the
whole run — defeating the sandbox. The entry deadline now also applies
to guest execution only: module compilation is host-side work whose
cost scales with module size, not plugin behavior, and under the new
instrumentation it could eat the entire execution budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bypass

The generic sink POST and http/langsmith/openllmetry/provider_logs
trend fetches built http.Clients directly from timeout_ms, so an unset
value meant no timeout at all and one hung endpoint stalled a
release-gate run until SIGINT. All integration HTTP calls now share
newIntegrationHTTPClient with the same 10s default httpjson already
used.

PostHog and Langfuse send credentials outside applyAuth (request body /
Basic auth), bypassing the CredentialEgressAllowed policy: a config
with project_token_env: OPENAI_API_KEY and an attacker base_url could
exfiltrate a provider secret. Both sinks now enforce the same egress
policy and fail loudly when it is violated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cleanr_generate_dataset passed a typed-nil *http.Client into the real
generation path, which nil-derefs on the first client.Do — and with no
recover() anywhere in the stdio server, the panic killed the whole
process and every session with it. The tool now gets a real client
matching the CLI generate command's provider timeout, and tool dispatch
is wrapped in recover() so a panicking handler returns a JSON-RPC error
while the server keeps serving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… replay artifacts

Extract the two copy-pasted writeFileAtomic helpers (snapshots, trends)
into cleanr/fsatomic, add fsync before the rename plus a best-effort
directory sync, and adopt it in the writers that were plain
os.WriteFile: an interrupted write could previously destroy the user's
hand-edited cleanr.yaml, corrupt profile.json and lose every stored
provider credential, or leave a torn attestation whose signature can
never verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zero-ambiguous fields were silently rewritten by applyDefaults:
require_review: false was inverted to true, max_normalized_drift: 0
(zero tolerance) became 0.3, min_score: 0 became 0.6, and the trend-gate
presets overrode an explicit enabled: value in both directions.

Following the existing MaxFailedCasesDelta precedent, these fields are
now pointer-typed with *Value() accessors that supply the defaults at
read time: DriftConfig's six thresholds, LLMJudgeConfig.MinScore,
ScenarioGenerationConfig.RequireReview, and TrendGateConfig.Enabled.
Presets fill Enabled only when unset, so exploratory + enabled: true
keeps relaxed thresholds with gating active (docs updated). Because the
accessors default at read time, SDK-built configs that bypass
applyDefaults now get correct thresholds too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The race detector was never run anywhere, hiding a real data race:
tests/cli/snapshot_test.go swapped the process-global
http.DefaultTransport while marked t.Parallel(), racing every parallel
test that makes an HTTP call. The swap test is now sequential (parallel
tests never overlap sequential ones, which is what makes the pattern
safe), and the CLI-adapter helper-process timeout is raised to 10s
since race instrumentation makes the re-exec'd binary slow to start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The initialize handshake ran under a sync.Once that stored the first
error forever: if the first scenario's deadline expired mid-handshake or
the server was briefly restarting, every remaining scenario in the run
failed instantly with the stale error. A failed attempt is no longer
cached — the next Invoke retries, with a mutex preserving Once's
serialization of concurrent first invokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes to the shared HTTP retry loop (used by the HTTP, OpenAI, and
Anthropic adapters):

- When a 429/503's Retry-After exceeded the remaining context budget,
  the response body was closed before being returned, so callers got
  'read on closed response body' instead of the actual rate-limit
  response. The body is now closed only after committing to another
  attempt — and drained first so the keep-alive connection is reused
  instead of forcing a new TCP+TLS handshake against an
  already-degraded server.
- Transport errors were retried for every method, but a connection can
  die after a request was fully delivered — replaying a POST there
  risks duplicate writes or double charges. Transport-error retries are
  now limited to idempotent methods (GET/HEAD/OPTIONS); 429/503
  responses still retry for all methods since the server explicitly
  rejected the request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tool results

Three protocol-compliance fixes for the stdio MCP server:

- Request ids are kept as raw JSON so id: 0 and id: "" round-trip
  exactly (omitempty previously dropped them, breaking client
  correlation), and parse errors now carry the spec-required id: null.
- The final request of a session without a trailing newline (common for
  one-shot piped clients) is processed at EOF instead of silently
  dropped.
- Ordinary tool execution failures return isError results the calling
  model can read and self-correct from; unknown tools are -32602 and
  contained panics remain -32603, instead of every failure surfacing as
  a blanket internal error that some clients treat as a broken server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alxxjohn and others added 2 commits July 2, 2026 16:07
Follow-up hardening: MCP init retry, safe HTTP retry semantics, MCP server protocol compliance
The catch-all tests/cli/cli_test.go had grown to 2914 lines, tripping
codeguard's max-file-lines gate once this branch touched it. Split by
topic — integrations, native sinks, dataset commands, dataset policy,
sync, trend history, trends/plugins commands — with shared helpers kept
in cli_test.go. Test code is unchanged apart from imports.

Also restructure TestExplicitRequireReviewFalseSurvivesLoad's assertions
into explicit got/want checks to satisfy codeguard's
conditional-assertion heuristic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alxxjohn alxxjohn merged commit fa3a2ab into main Jul 2, 2026
16 checks passed
@alxxjohn alxxjohn deleted the improve branch July 2, 2026 20:37
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