test: exercise the legacy-contract migration fallback before it is needed - #65
Conversation
`legacy_contracts.toml` has been empty since it was added on
2026-04-30, so the migration path it feeds has never executed with a
non-empty registry: not in production, not in CI. The machinery is
wired, but the first time it runs would be the first real re-key, on
live user data, with no prior evidence it works.
Adds a fake Freenet gateway (in-process, real loopback WebSocket, real
bincode frames) and drives the real client against it:
* tests/legacy_fallback.rs - probe order, recovery from a
predecessor key, current-key precedence, empty-response handling,
transient-timeout retry, and absence reporting.
* tests/legacy_registry_codegen.rs - the build.rs registry reader:
TOML entry -> generated const -> a contract key that matches the
predecessor WASM's own stdlib-derived key.
* git-remote-freenet migration_tests - the forward re-PUT to the
current key, its durability, and the rejected-PUT degraded path.
Expected legacy keys are derived with the stdlib's
`from_params_and_code` over synthetic predecessor WASM, never with the
`contract_id_from_wasm_hash` shortcut under test, so a drifted shortcut
fails the tests instead of agreeing with itself.
Two small seams, no behavior change: build.rs helpers made `pub` so a
test can `include!` them, and `fetch_repo_state` split so the registry
can be injected (production still passes the generated const, and the
hash and its description now come from the same slice).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
…d of hanging Found by mutation testing: with the legacy probe loop disabled, the healer's `loop` waited forever for a probe that never arrived and `tokio::join!` hung the test rather than failing it. A test that hangs on a regression reports a CI timeout instead of a diagnosis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
The seam made everything downstream of the registry argument testable, but left one line unobservable: while `legacy_contracts.toml` is empty, `fetch_repo_state` passing `&[]` behaves identically to passing `LEGACY_REPO_CONTRACT_WASM_HASHES`. A regression there would surface only at the first genuine re-key, which is the scenario these tests exist to de-risk. Source-scrape pin, with anti-vacuity guards: it fails loudly if the wrapper is renamed, matches whitespace-stripped so rustfmt cannot disarm it, and lives in an integration file scraping a different file so it can neither self-match nor be cut with a `mod tests` block. Mutation-checked: passing `&[]` fails this test and only this test (47 others stay green, which is the point); renaming the wrapper trips the "this pin is now testing nothing" guard rather than passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #65
Tier: Light — test-only. Production diff is a build.rs visibility change (inert; nothing links a build script) and a fetch_repo_state → fetch_repo_state_from_registry split where the wrapper passes the same constant the old code read. wsclient.rs untouched.
Reviewers run: adversarial deep read (Fable), with four of the eight mutations independently re-run rather than taken from the table.
Findings
No blocking findings.
Mutations reproduced, not trusted. M1 (both fail-fast modes), M3, M4 and M6 were re-run in an isolated copy; each genuinely mutates production code and reproduced the reported kill set. M3 killed 10 rather than the reported 9 — a pre-existing unit test also catches it — so the table under-counted in the harmless direction.
The --no-fail-fast discovery is real and worth carrying forward. A multi-target mutation sweep under cargo's defaults stops after the first failing target, so kills in later targets are never observed and coverage looks absent when it isn't. It affects sweep counting only, not CI redness — but it is a check that quietly reports "nothing here" when the truth is "I stopped looking", and it produced a false negative in this very PR before being caught.
The fake gateway is faithful enough to mean something — real WebApi recv loop over a real tungstenite loopback with real bincode framing, so drift would fail the connection rather than silently pass.
The hang fix holds. Under M1 the retry test now fails in 5.01s via HEAL_DEADLINE instead of hanging tokio::join! forever, and no other test in the new files shares the unbounded-poll pattern. A test that hangs rather than fails is worse than one that fails, so this mattered.
The added registry pin closes a genuine blind spot. That production passes the real LEGACY_REPO_CONTRACT_WASM_HASHES (rather than &[], or the wrong constant) was unobservable by the entire suite while the registry is empty — M9 confirms it: substituting &[] reddens only the new pin, with all 47 other tests green. Its three anti-vacuity guards (fails loudly if the wrapper is renamed, whitespace-stripped comparison so cargo fmt cannot disarm it, lives in a different file from the one it scrapes) are the right precautions for a source pin.
Note for whoever performs the first re-key
a_comments_only_registry_generates_an_empty_array is a deliberate canary: it is meant to go red the day a real [[entry]] is added, and its failure message says so. Update the assertion; do not delete the test.
Verdict
Ready to Merge. HEAD reviewed: ab5a4c1.
Separately filed during this work: freenet-git#64 — pack contracts have no legacy-migration path at all, so a pack-contract re-key would migrate repo state forward while making every bundle it references unreachable. Relevant to #63.
[AI-assisted - Claude]
Problem
Freenet contract keys are content-addressed —
ContractKey = BLAKE3(BLAKE3(wasm) || params)— so any change torepo-contract.wasmre-keys every repo and orphans the state stored under the old key.legacy_contracts.tomlpluswsclient::get_state_with_legacy_fallbackis this crate's remedy: record predecessor WASM hashes, and on a miss at the current key walk backwards through them.That registry has been empty since the day it was added, and the fallback has therefore never run. Verified against current
main:legacy_contracts.tomlhas exactly one commit in its history (53276c6, 2026-04-30) and contained zero[[entry]]blocks then and now.contracts/repo-contract.wasmhas not changed since 2026-04-30 (b8edf90), so no re-key has happened across 0.1.1 → 0.1.27.legacy_hashesis an empty slice, so the legacy loop body inprobe_all_keyshas provably never executed in any shipped binary, andfetch_repo_state's migration branch has never been taken.The existing tests cover the path's pure functions in isolation —
dispatch_get_response,ProbeOutcome,outcomes_worth_retrying,format_fallback_failure,contract_id_from_wasm_hash— but nothing drives the sequencing: probe the current key, walk the predecessors in registry order, hand back what was found, re-PUT it forward. Nor has thebuild.rsTOML parser ever parsed an[[entry]]block.The risk is not that the machinery is wrong today. It is that the first time it runs will be the first real re-key — which #63 already has queued — on live user data, with no prior evidence it works.
Approach
Tests only; no behavior change.
A fake Freenet gateway (
tests/support/fake_gateway.rs) that speaks the real wire protocol — bincode-encodedClientRequest/HostResponseframes over a real loopback WebSocket — so the real client code runs its real recv loop. Per this repo's testing philosophy a live node is reserved for transport concerns, and this is not one; the gateway is the smallest thing that lets the migration sequencing execute. It records every GET and PUT in arrival order, so probe order and the migration write are assertable rather than inferred.Three test surfaces:
tests/legacy_fallback.rs(6) — recovery from a predecessor key, current-key precedence with no wasted probes, registry-order walking with the correct reported index, empty-response-is-not-recovery, transient-timeout retry, and absence reporting.tests/legacy_registry_codegen.rs(5) — thebuild.rshalf: TOML[[entry]]→ generated const → a contract key that matches the predecessor WASM's own stdlib-derived key. Plus order preservation, the empty shipped registry, and the two loud-failure paths.git-remote-freenetmigration_tests(3) — the forward re-PUT to the current key, that it lands on the current key with the recovered bytes intact, that a second client then reads it directly without falling back, and that a rejected migration PUT still returns the user's repo.One of the seven in
legacy_fallback.rsis a source-scrape pin rather than a behavioural test, covering the one line the seam makes untestable: while the registry is empty,fetch_repo_statepassing&[]behaves identically to passingLEGACY_REPO_CONTRACT_WASM_HASHES, so no behavioural test can tell them apart, and a regression there would surface only at the first genuine re-key. It carries three anti-vacuity guards — it fails loudly if the wrapper is renamed, compares whitespace-stripped socargo fmtcannot disarm it, and lives in an integration file scraping a different file so it can neither self-match nor be switched off by deleting a#[cfg(test)] mod testsblock.Oracle independence
Every expected legacy key is derived with the stdlib's
ContractInstanceId::from_params_and_codeover synthetic predecessor WASM bytes — never withwsclient::contract_id_from_wasm_hash, the shortcut under test. Deriving the expectation from the shortcut would make the tests self-consistent rather than correct: they would keep passing if the shortcut drifted from the real derivation and every migration probed a key that never existed. Mutation M3 below confirms the distinction is load-bearing.Two seams (no behavior change)
build.rs:Entry,parse_legacy_entries,generate_codemadepubso a test caninclude!the file. Visibility in a build script is otherwise meaningless — nothing links against it.fetch_repo_statesplit into a thin wrapper overfetch_repo_state_from_registry(..., registry). The generated const is empty in every build and can only be filled by editing the TOML and rebuilding, so tests cannot otherwise reach the migration branch. Production still passesLEGACY_REPO_CONTRACT_WASM_HASHES. Incidental improvement: the probe hashes and the description used in the log line now come from the same slice instead of two separate reads of the const, so they cannot disagree.Testing
15 new tests. Full workspace suite green,
cargo fmt --checkclean,cargo clippy --workspace --all-targets -- -D warningsclean.Mutation evidence
A test that runs the fallback but would pass even if the fallback were broken is worse than no test. Ten mutations were applied to production code one at a time, the suite run, and the mutation reverted. All ten were killed; no survivors. Expected outcomes were written down before running.
M9 is the one that justifies the pin existing: with the wrapper passing an empty registry, all 47 other tests still pass. Nothing else in the suite can see that line.
probe_all_keys: never probe legacy keyscontract_id_from_wasm_hash: swap hash/params ordermigration_tests(3)generate_codeemits bytes reversedflush)&[]instead of the real registry"this pin is now testing nothing"✅The patches, verbatim:
Full run output:
Two things the mutation run caught
A false negative in the first sweep. The first run reported M1/M2/M3 killing only the three
migration_tests, with the sixlegacy_fallbacktests apparently indifferent to the legacy probe loop being deleted. That was cargo's default fail-fast stopping after the first failing test binary, not weak tests — the other two targets never ran. Re-run with--no-fail-fast(table above). Worth recording: a mutation sweep across multiple test targets silently under-reports without that flag, which is exactly the direction that manufactures false confidence about coverage.A hang in one of the new tests, now fixed (second commit). Under M1 the retry test's healer task polled forever for a predecessor probe that never arrived, so
tokio::join!hung instead of failing. A test that hangs on a regression reports a CI timeout rather than a diagnosis. The healer now has a deadline, and M1 kills the test properly.One test is a deliberate canary — update it, do not delete it
a_comments_only_registry_generates_an_empty_arrayasserts the shippedlegacy_contracts.tomlstill has no[[entry]]blocks. It is meant to go red the day someone adds the first real entry, and its failure message says so. That is intentional friction at exactly the right moment: the first re-key is when the migration path stops being hypothetical, and it should force a deliberate look rather than sliding through. Whoever does that re-key should update the assertion to the new expected state, not remove the test.Findings
The fallback works. Nothing here is a bug fix — both halves behave as documented, including the details that are easy to get wrong: probe order, the
!state.is_empty()guard, the index reported back to the caller, and the degraded path when the migration PUT is rejected.One gap, filed separately: pack contracts have no migration path at all.
LEGACY_REPO_CONTRACT_WASM_HASHEScovers only the repo contract. Object bundles live in pack contracts keyed byBLAKE3(BLAKE3(pack_wasm) || pack_hash), andwsclient::get_pack/chunked::fetch_chunked_pack_with_progressderive that key from the currentpack_wasmwith no fallback. Ifpack-contract.wasmis ever rebuilt — a stdlib or toolchain bump rebuilds both contracts together — the repo state migrates forward and every bundle it references becomes unreachable, so a fresh clone would list refs and then fail to fetch objects. Relevant to #63, which documents the re-key procedure for the repo contract only. See #64.Refs freenet/freenet-core#2776.
[AI-assisted - Claude]