fix(put): deliver originator-loopback failure via PutMsg::Error bypass#4126
fix(put): deliver originator-loopback failure via PutMsg::Error bypass#4126Basedfloppa wants to merge 8 commits into
Conversation
Issue freenet#4111: a client PUT that hit the originator-loopback path and was rejected locally by put_contract (e.g., wrapper-contract version check) burned the full retry budget against the same deterministic failure and surfaced "put failed after N attempts (last error: failed notifying, channel closed)" instead of the contract-side reason. The race was inside OpManager::send_client_result: it issued the real error (M1) and the TransactionCompleted cleanup (M2) sequentially via two try_send calls. When M2 ran first, pending_op_results[client_tx] was removed, which closed the originator's send_and_await reply channel. The retry-loop driver saw the close as NotificationError, classified it as wire_error, and advanced through three more peers — each of which looped back, re-ran the same put_contract validation, failed identically, and closed its own slot. Option 1 from the issue: add a PutMsg::Error { id, cause } wire variant and route the loopback failure through send_local_loopback instead of the out-of-band send_client_result. The bypass in handle_pure_network_message_v1 forwards PutMsg::Error to pending_op_results[tx] like any other terminal reply. PutRetryDriver classifies it as Terminal(Err(cause)), drive_retry_loop returns Done(Err(_)), and run_client_put publishes exactly one HostResult::Err(OperationError { cause }) carrying the real reason. Symmetric with the success path. No new variant on AttemptOutcome / RetryLoopOutcome: PUT's Terminal type becomes Result<ContractKey, String>, so the existing Done arm carries both shapes through a single match site. Regression coverage: - put_msg_error_{id_and_display,serde_roundtrip,has_no_requested_location} in operations/put.rs — pin the wire shape. - classify_reply_error_is_terminal_error_with_cause and put_retry_driver_terminal_error_does_not_advance in put/op_ctx_task.rs — pin the classifier wiring. - run_relay_put_publishes_error_on_loopback_failure updated to assert send_local_loopback(PutMsg::Error) replaces send_client_result. - put_branch_bypass_includes_error_variant_regression_guard in node.rs — pin that the bypass terminal-gate lists Error alongside Response / ResponseStreaming so the dispatch wildcard doesn't drop it. End-to-end coverage: existing test_put_error_notification (crates/core/tests/error_notification.rs) verifies the client receives a response rather than hanging. With this fix the response now carries the contract-side reason; tightening that assertion belongs to a follow-up since it needs a wrapper-contract that emits a stable reason string. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ehavioural cases The original fix commit shipped wire-format and structural pins. This follow-up adds: - **Boundary tests**: empty and multi-KB `cause` strings must round-trip through `classify_reply` unchanged. Pins against silent truncation or placeholder substitution — the contract-side reason reaches the client byte-for-byte. - **Live-path classification**: a `PutMsg::Error` reply driven through the actual `RetryDriver::classify` trait method (not just source-text grep) must produce `AttemptOutcome::Terminal(Err(cause))`. Catches a future classify rewrite that bypasses the structural pin. - **Companion happy-path**: a `Response` reply must still produce `Terminal(Ok(key))`. Pins against an accidental Ok/Err inversion introduced when `Terminal` widened to `Result<ContractKey, String>`. - **Done(Err) arm pin**: structural assertions that the `run_client_put` `Done(Err(cause))` arm calls `op_manager.completed(client_tx)`, publishes `ErrorKind::OperationError`, and does NOT advance/retry. - **op_ctx end-to-end loopback**: `send_local_loopback` carries a `PutMsg::Error` envelope through to the event-loop receiver with `target_addr=None` and a closed reply sender — the same shape used by the successful `Response` loopback, so the bypass forwards both through identical infrastructure. Sanity: full `cargo test -p freenet --lib --no-fail-fast` reports 2502 passed, 0 failed, 14 ignored. PUT suite now 52 tests (was 47); `send_local_loopback` suite 3 (was 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
I have all the information I need. Here is the complete review. Rule Review: No blocking issues; one style noteRules checked: operations.md, code-style.md, testing.md, git-workflow.md WarningsNone. Every criterion was evaluated:
Info
Rule review against |
iduartgomez
left a comment
There was a problem hiding this comment.
Comprehensive review + proposed follow-up commit
Four-perspective review (code-first, testing, skeptical, big-picture) plus an independent skeptical pass. The implementation correctly closes #4111 and the test scaffolding is solid. Below are the items that came out of review with a proposed follow-up commit at the end.
Blocking
- Wire-format compat for the new variant.
PutMsglacks#[non_exhaustive]and there's no*_wire_format_is_stablepin. Older 0.2.x peers that ever receivePutMsg::Errorwill panic in pattern-match arms instead of failing gracefully at bincode deserialization. Even though Error is currently loopback-only, the enum isSerialize/Deserializeand any future change that lets it leak over the wire would be a silent-break. send_local_loopbackfailure leaks the slot. The warn-only branch atop_ctx_task.rs:797-806drops the client on shutdown and leakspending_op_results[incoming_tx]until the 60s GC sweep.op_execution_senderandresult_router_txare independent channels — one may be alive while the other is tearing down. The pre-#4111 code hadsend_client_result+completed()unconditionally; the PR should restore this as a fallback when the bypass send fails.- PR description claims a fallback that doesn't exist. Description says "A fallback to direct
send_client_resultis preserved … so the 'client always sees something' guarantee survives." Code only logstracing::warn!. Either restore the fallback (recommended) or reword the description.
Important
- Multi-hop cause loss. At
op_ctx_task.rs:1242-1253, a downstream relay'sPutMsg::Errorfalls through toother =>→OpError::UnexpectedOpState. On a loopback relay the client sees"UnexpectedOpState"instead of the real reason; on a non-loopback relay it disappears entirely. The PR description claimsErroris reserved for cross-cutting failure but the routing doesn't realize that intent. cause: Stringis unbounded. No size cap onPutMsg::Error.causeorPutTerminalError. Future relay-side use becomes a DoS amplification surface (attacker-controlled upstream emits multi-MB causes; relay forwards verbatim). The PR's own large-cause test pins 8 KiB round-trip without truncation, locking in the unbounded-by-design behaviour.put_retry_driver_classify_error_returns_terminal_errexercises an in-test re-implementation (InTestPutClassifier), not productionPutRetryDriver::classify. The test's own doc-comment admits this. Either invoke the real driver or testclassify_replydirectly (it's the helper the real driver delegates to).test_put_error_notificationis too lenient. Accepts "any response" including the synthesized"failed notifying, channel closed"marker — i.e., the original bug class would slip through. The test should reject that specific synthesized string..claude/rules/operations.mdshould grow the race-class rule. This is the second instance of the "terminal reply viasend_client_resultracesTransactionCompletedcleanup" pattern (first was the original loopback Response migration). Without distilling it into the rules file, GET / SUBSCRIBE / UPDATE work that grows synchronous-failure paths will rediscover the same trap.
Suggestions
- Multi-attempt assertion via runtime counter rather than text-grep on
!arm_body.contains("advance"). - Replace the most whitespace-fragile source-grep tests with macro/trait-driven structural pins (the existing
matches!(\n op,substring is brittle tocargo fmt). - File a follow-up issue for the wrapper-contract end-to-end test the #4111 issue body requested (asserting the contract-side cause survives intact).
Proposed follow-up commit
I've prepared a commit on a local branch (pr-4126) that addresses every item above. Summary:
#[non_exhaustive]onPutMsg+put_msg_wire_format_variant_tags_are_stablepinning every bincode tag (0..=5).- Shutdown-path fallback restored:
send_local_loopbackfailure now emitssend_client_result+op_manager.completed(incoming_tx)so the slot releases synchronously. - New
relay_put_send_errorhelper + match arm at the relay reply site so downstreamPutMsg::Errorcauses propagate through multi-hop chains via the same envelope. - New
PUT_TERMINAL_CAUSE_MAX_BYTES = 2048constant + UTF-8-safebound_causetruncator applied on every wire entry point.PutTerminalError::from_wiretruncates with a…[truncated]marker. classify_reply_for_put_msg_error_returns_terminal_error+ companion_response_returns_storedreplace theInTestPutClassifiershim with direct production-helper tests.test_put_error_notificationasserts the response does NOT contain the synthesizedfailed notifying, channel closedmarker.- New rule "WHEN publishing a terminal operation reply" in
.claude/rules/operations.mdciting #4111 + the original loopback Response fix. - Runtime companion
try_forward_driver_reply_delivers_put_msg_errorfor the bypass-gate pin (complements the existing structural test). run_relay_put_publishes_error_on_loopback_failureupdated to permit the new shutdown fallback while still pinningsend_local_loopbackas the primary path.- Filed #4147 for the deferred end-to-end + runtime-counter coverage.
Sanity:
cargo test -p freenet --lib→ 2508 passed, 14 ignored.cargo clippy -p freenet --tests -- -D warningsclean.cargo fmt --checkclean.
Patch is attached below. To apply on your branch:
gh pr checkout 4126
curl -sSL <gist-or-attachment-link> | git apply
git commit -am "fix(put): address PR #4126 review — wire-compat, shutdown fallback, multi-hop cause, length cap"
git pushOr pull from my branch directly if you'd like — I can push to a public fork on request.
The full commit message + diff:
commit ed6ef1d3
fix(put): address PR #4126 review — wire-compat, shutdown fallback, multi-hop cause, length cap
Addresses every blocking + important item from the four-way review of
PR #4126 (closes #4111). Summary of changes:
[...trimmed for brevity — see the patch file...]
Happy to split into multiple commits or rebase into your existing two commits if preferred. The diff is +537 / -143 across 5 files (.claude/rules/operations.md, crates/core/src/node.rs, crates/core/src/operations/put.rs, crates/core/src/operations/put/op_ctx_task.rs, crates/core/tests/error_notification.rs).
Second review pass (delta
|
Addresses iduartgomez review on PR freenet#4126: B1 — bubble multi-hop local failures upstream. `run_relay_put`'s non-loopback Err branch now emits PutMsg::Error to upstream_addr via relay_put_send_error; pre-fix, a put_contract rejection on an intermediate relay silently exited and the upstream's send_to_and_await hung until OPERATION_TTL, burning the originator's retry budget. B3 — eliminate the M1/M2 race in the shutdown fallback. `completed()` is no longer invoked from the loopback fallback, and the fallback is extracted as `dispatch_loopback_shutdown_fallback` taking only `&mpsc::Sender<(Transaction, HostResult)>` — compile-time guarantee that no future refactor can quietly re-introduce the TransactionCompleted emission. Slot reclamation falls to the 60s sweep, a slot leak under shutdown for guaranteed race-freedom. B2 — replace tautological retry-loop test. The previous version called only `classify_reply` (free fn, no driver, no path to advance()) and asserted a counter advance() couldn't move. Split into an honest classify pin (operations::put) plus a structural pin on drive_retry_loop's Terminal arm (operations::op_ctx) that forbids `.advance()` and `continue`. M1 — behavioural coverage of the multi-hop bubble. Extracted `relay_put_send_error_with_ctx` taking `&mut OpCtx + own_addr` so tests drive it directly via `event_loop_notification_channel` without a full OpManager. Four behavioural unit tests + two structural pins (bound_cause re-bound at the relay reply arm, B1 emission at the non-loopback Err branch) + 2-node `test_put_error_notification_multi_hop` integration test. M2 — `bound_cause_never_splits_utf8_codepoint` now actually exercises the walk-back loop. The previous 3-byte codepoint ("好") with budget 2034 landed exactly on a boundary (`2034 % 3 == 0`) and ran zero iterations; replaced with 4-byte "𝄞" (U+1D11E, `2034 % 4 == 2`). Sanity-asserts `!is_char_boundary(raw_budget)` so a future cap change can't silently re-degrade the test to a no-op. M3 — shutdown fallback covered behaviourally (see B3 extraction). Four tokio-channel tests pin: exactly-one publish to result_router_tx with verbatim cause; notifications channel stays silent (no TransactionCompleted from this helper); closed router → log+drop, no panic; full router → `try_send`-based, returns within 100ms. Minor — off-by-one cap boundary test (`bound_cause_cap_boundary_is_inclusive`); bound_cause idempotency invariant ("verbatim-or-rebound, never append") PUT lib suite: 47 → 68 tests, all green. clippy + fmt clean.
|
Sorry for the hold up, life got in the way, hope this addresses all blockers and minor request to your satisfaction. |
Multi-model review —
|
Review B1 for PR freenet#4126 / issue freenet#4111: the streaming relay PUT driver silently converted downstream failures into false upstream successes. Two gaps, both now fixed symmetrically with the non-streaming `run_relay_put` / `drive_relay_put` pair: 1. `drive_relay_put_streaming`'s reply match had only `Response`/`ResponseStreaming` arms plus a catch-all `other =>` that called `relay_put_send_response(...)` — so a downstream `PutMsg::Error` (now deliverable since node.rs forwards it through the bypass) was reported upstream as a STORED success. Added an explicit `PutMsg::Error` arm that bubbles the cause upstream via `relay_put_send_error`, and the `other =>` arm now returns `Err(OpError::UnexpectedOpState)` instead of fabricating a success. 2. `run_relay_put_streaming` discarded `drive_relay_put_streaming`'s `Err` after a log line — a streaming relay's local failure (orphan-claim, assembly/deserialize, key mismatch, `put_contract` rejection) just left the originator's `send_and_await` waiter to hang to `OPERATION_TTL`. Added the loopback / non-loopback error-emit path mirroring `run_relay_put`: loopback emits `PutMsg::Error` via `send_local_loopback` (with the `dispatch_loopback_shutdown_fallback` shutdown path), non-loopback bubbles via `relay_put_send_error`. The `release_pending_op_slot` call is now gated on `!originator_loopback` for the same reason as `run_relay_put`. Adds two structural regression pins: `drive_relay_put_streaming_bubbles_downstream_error` and `run_relay_put_streaming_emits_terminal_error_on_local_failure`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update on PR #4126 review blockers — B1 fixed, B2 root-causedPushed B1 — FIXED (
|
Disposition — needs author/maintainer to land B2@Basedfloppa @iduartgomez — summary of where this PR stands after the multi-model review above:
I deliberately did not push a B2 fix: it touches core event-loop dispatch and the This PR can't merge until B2 is resolved (its own regression test flakes with the #4111 symptom), so I'm leaving it open for that. The core loopback fix and B1 are in good shape. [AI-assisted - Claude] |
…et#4126 B2) When handle_op_execution processes a fire-and-forget callback (callback is closed), the originator's pending_op_results[client_tx] may still contain the client's waiter. Previously, orphan-wake registration (live_tx_tracker.add_transaction) was performed regardless of callback state, so transient connection churn to the downstream peer would trigger handle_orphaned_transactions → TransactionCompleted(client_tx) → pending_op_results.remove(client_tx) — destroying the very slot through which the bypass-delivered PutMsg::Error needs to arrive. The originator then receives a synthesised "channel closed" error instead of the real contract-side failure cause. Fix: extract callback_closed before the if-else and use !callback_closed && to guard the orphan-wake registration. When the callback is closed, skip registration entirely — the originator either gets the real PutMsg::Error via the bypass path, or times out gracefully via attempt_timeout. Fixes: freenet#4126 B2 See: freenet#4111, freenet#4154
|
Given the hold-up in resolving the last blocker, I took the initiative into my own hands. For B2, approach (a) was chosen, as the core idea of this PR was to allow the real issue to surface as a terminal error without infrastructure masking it. I hope my changes will suffice as a fix for this issue. |
Problem
When a client PUT hits the originator-loopback path (the default when
fdevconnects to the same node that holds the contract code) andput_contractrejects the update, the retry loop spins through its full retry budget — each iteration repeating the same failing local validation — and the client receives a genericput failed after N attempts (last error: failed notifying, channel closed)instead of the actual contract-side rejection reason.Whether the real cause ever surfaces depends on a race inside
OpManager::send_client_result:try_sendof the realHostResult::Errtry_sendofTransactionCompletedcleanupIf the event loop processes M2 first, it removes the
pending_op_results[client_tx]callback before the originator'ssend_and_awaitconsumes M1. The retry-loop driver sees the closed reply channel asNotificationError, classifies it aswire_error, and callsadvance()— re-entering the loopback with a freshattempt_tx, re-running the same failingput_contract, and repeating up to the retry cap. Even when the race goes the right way and the real error reaches the client, the server has burned three extraput_contractround-trips for nothing — observable as multiplied CPU under load and inflatedorphan_streamsaccounting.Reproduced on a live gateway (freenet-core 0.2.56): 24+ pairs of
put_contract failed/relay_put_errorlog lines in a 10-minute window for a single client retry session against a wrapper contract that enforces strictly-increasingstate.version.Solution
Option 1 from #4111: route the terminal failure through the same
pending_op_resultsbypass that deliversPutMsg::Responseon success, sodrive_retry_loopclassifies it once and publishes once.Wire envelope. New variant
PutMsg::Error { id, cause: String }is appended at the end of thePutMsgenum so the bincode discriminants for every pre-existing variant stay byte-stable. The enum is now#[non_exhaustive]so future additive variants don't require a wire-format break; wildcard arms downstream consume the safety net (see.claude/rules/git-workflow.md→ "WHEN bumping freenet-stdlib"). A wire-tag pin test locks the variant order against silent reordering and documents the bincode codec-config assumption.DoS cap.
PUT_TERMINAL_CAUSE_MAX_BYTES = 2048+ a UTF-8-safe truncatorbound_cause(with...[truncated]marker) is applied at every entry point that builds or forwards aPutMsg::Error:PutTerminalError::from_wire,run_relay_put's loopback emit, the multi-hop bubble indrive_relay_put's reply arm, and the newrun_relay_putnon-loopback error path. Idempotent on already-bounded inputs, documented as "verbatim-or-rebound, never append".Loopback flow.
run_relay_put's originator-loopback failure path dispatchesPutMsg::Errorviasend_local_loopbackinstead of callingsend_client_resultdirectly:handle_pure_network_message_v1forwardsErrortopending_op_results[tx]like any other terminal reply (Response | ResponseStreaming | Error— pinned byput_branch_bypass_includes_error_variant_regression_guard).classify_replymaps it toReplyClass::TerminalError { cause }.PutRetryDriver::Terminalis widened fromContractKeytoResult<ContractKey, PutTerminalError>soTerminal(Err(cause))carries the typed error through a single match arm (no newAttemptOutcome/RetryLoopOutcomevariant).drive_retry_loopreturnsDone(Err(cause))andrun_client_putpublishes exactly oneHostResult::Err(OperationError { cause })carrying the real contract-side reason.Symmetric with the success path. The failure now rides the bypass the same way a
Responsedoes — no out-of-band channel, no race againstTransactionCompleted, no retry storm against a deterministic local failure.Multi-hop bubble. New
relay_put_send_errorhelper forwards a downstreamPutMsg::Errorone hop further upstream when a non-final relay receives it:drive_relay_put's reply match adds aPutMsg::Error { cause }arm that re-bounds the cause and dispatches viarelay_put_send_error. The receiving node's bypass acceptsError(gated structurally byput_branch_bypass_includes_error_variant_regression_guard), so the envelope rides the chain Origin → R1 → … → R_failed without ever falling through toUnexpectedOpState.run_relay_put's non-loopback error path is symmetric: when the local driver'sdrive_resultisErrand we're not the originator,relay_put_send_error(upstream_addr)emits the envelope so the upstream'ssend_to_and_awaitwaiter receives a real terminal instead of hanging untilOPERATION_TTL.Shutdown fallback. When
send_local_loopbackfails on a closedop_execution_sender(node shutdown), the loopback path hands off to a new free-function helperdispatch_loopback_shutdown_fallbackthat publishes aHostResult::Errstraight toresult_router_txso the WS client still sees the real cause. The helper's signature takes only&mpsc::Sender<(Transaction, HostResult)>— it has no access toOpManager, so it physically cannot invokeop_manager.completed(tx)(the pre-#4111 race shape) or emitTransactionCompletedon the notifications channel. This is a compile-time guarantee against M2-side regressions:pending_op_results[tx]is reclaimed by the 60 s sweep, a slot leak under shutdown for guaranteed race-freedom. Mirrors therelease_pending_op_slot_onextraction pattern inop_state_manager.rs(review finding T-3).Testing
Wire-format and structural pins.
put_msg_error_{id_and_display, serde_roundtrip, has_no_requested_location}— wire shape of the new variant.put_msg_wire_format_variant_tags_are_stable— bincode tag stability across all six variants, with explicit codec-config assumption note (defaultu32LE; switching towith_varint_encoding/with_big_endianwould silently break the wire format).put_branch_bypass_includes_error_variant_regression_guard— PUT dispatch terminal-gate listsErroralongsideResponse/ResponseStreaming.Classification + driver behaviour.
classify_reply_error_is_terminal_error_with_cause—PutMsg::Error↦ReplyClass::TerminalError { cause: verbatim }.classify_reply_error_maps_to_terminal_error_with_cause— explicit chain pin (PR review item B2: replaces the previous tautologicaldrive_retry_loop_done_err_does_not_call_advancethat called onlyclassify_replyand asserted a counter that classify can't move; the new test is honest about scope and composes with the structural pin inop_ctx).drive_retry_loop_terminal_arm_does_not_call_advance(incrate::operations::op_ctx::tests) — structural pin ondrive_retry_loop'sAttemptOutcome::Terminalarm: mustreturn RetryLoopOutcome::Done(value);synchronously, must not contain.advance()orcontinue.put_retry_driver_terminal_error_does_not_advance— pin onPutRetryDriver::classifymappingTerminalError↦Terminal(Err(_)).run_client_put_done_err_arm_publishes_once_and_completes— pin on theDone(Err)arm: callsop_manager.completed(client_tx), publishesErrorKind::OperationError, does NOT advance.Boundary cases.
classify_reply_error_with_{empty_cause_preserves_empty, normal_cause_passes_through, oversize_cause_is_truncated}— cause-string round-trip guards.bound_cause_{short_string_passes_through, truncates_oversize_at_char_boundary, never_splits_utf8_codepoint, cap_boundary_is_inclusive}— PR review M2 + minor: thenever_splits_utf8_codepointtest now uses a 4-byte codepoint ("𝄞"U+1D11E,2034 % 4 == 2) so the walk-back loop is actually exercised; the previous 3-byte"好"(2034 % 3 == 0) landed exactly on a boundary and the loop ran zero iterations. The newcap_boundary_is_inclusivetest pins the off-by-one aroundPUT_TERMINAL_CAUSE_MAX_BYTES(verbatim at== MAX, truncated atMAX + 1).put_terminal_error_from_wire_truncates— wire-side cap enforcement.Loopback + multi-hop dispatch.
send_local_loopback_carries_put_error_to_event_loop— op_ctx-level end-to-end:Errorenvelope flows withtarget_addr=None.run_relay_put_publishes_error_on_loopback_failure— pinned that the loopback emit goes throughsend_local_loopback(PutMsg::Error); the shutdown fallback usessend_client_resultbut MUST NOT callop_manager.completed(incoming_tx)(PR review B3 — pre-PUT originator-loopback error path triggers wasteful retries and risks hiding the real error from the client #4111 race shape forbidden).relay_put_send_error_with_ctx_{uses_loopback_when_own_addr, uses_fire_and_forget_to_upstream, unknown_own_addr_uses_fire_and_forget, errors_on_closed_channel}(PR review M1): behavioural unit tests against the extractedrelay_put_send_error_with_ctxhelper usingevent_loop_notification_channel. Each test drives the helper end-to-end against a realOpCtx, captures the outboundOpExecutionPayloadfrom the receiver, and asserts (a) the envelope isPutMsg::Error { id, cause: verbatim }, (b)target_addrisNonefor loopback /Some(upstream)otherwise, (c)own_addr=Nonefails closed via fire-and-forget rather than masking as phantom loopback, and (d) a closed executor channel surfaces asOpError::NotificationError.drive_relay_put_error_arm_rebounds_cause_before_bubble— pins the multi-hop arm callsbound_cause(downstream_cause)+relay_put_send_error(... upstream_addr).run_relay_put_bubbles_local_failure_in_non_loopback_mode— pins the B1 fix: the non-loopback Err branch inrun_relay_putcallsbound_cause(err.to_string()) + relay_put_send_error(... upstream_addr).Shutdown fallback (PR review B3 + M3).
dispatch_loopback_shutdown_fallback_publishes_to_result_router— behavioural: helper publishes exactly one(incoming_tx, Err(OperationError { cause: verbatim }))toresult_router_tx. No second entry.dispatch_loopback_shutdown_fallback_does_not_emit_transaction_completed— wires a real notifications channel side-by-side with the result router and asserts the notifications channel stays empty after the helper runs. The compile-time half of the guarantee is the helper's signature (only&mpsc::Sender<(Transaction, HostResult)>, no access toOpManageror notifications sender). Together these guard against re-introducing the pre-PUT originator-loopback error path triggers wasteful retries and risks hiding the real error from the client #4111 M1/M2 race shape under shutdown.dispatch_loopback_shutdown_fallback_does_not_panic_on_closed_router— receiver dropped, helper logs and returns without unwinding.dispatch_loopback_shutdown_fallback_does_not_block_on_full_router— capacity-1 channel pre-filled, helper returns within 100 ms (provestry_sendnotsend().await, per.claude/rules/channel-safety.md).End-to-end.
tests/error_notification.rs::test_put_error_notification(single-node) — keeps verifying the client doesn't hang, and asserts the response does NOT contain theFORBIDDEN_MARKER = "failed notifying, channel closed"— explicit guard against the synthesised infrastructure-leak string ever surfacing to clients.tests/error_notification.rs::test_put_error_notification_multi_hop(PR review M1 part 2) — 2-node ring["gateway", "peer-a"]; client connects topeer-a(NOT the gateway), so the PUT enters at a non-originator node and the wire path ispeer-a (client) → peer-a (originator-loopback relay) → gateway. SameFORBIDDEN_MARKERassertion as the single-node variant, plus a load-bearing 30 s timeout — if the multi-hop bubble regresses (bypass stops acceptingPutMsg::Error, ordrive_relay_put's reply arm falls back toUnexpectedOpState, orrun_relay_put's non-loopback Err branch stops emitting), the single-node test still passes but this one panics.Tightening either E2E to assert on the actual contract-side cause string needs a wrapper contract with a deterministic, asserted-on failure reason — tracked as follow-up #4147.
Sanity.
cargo test -p freenet --lib operations::put→ 68 passed, 0 failed (PUT lib suite expanded from 47 → 68 — wire pins + classification + boundary + multi-hop unit + M3 behavioural).cargo test -p freenet --lib operations::op_ctx→ 15 passed, 0 failed (includes the newdrive_retry_loop_terminal_arm_does_not_call_advancepin).cargo test -p freenet --test error_notification test_put_error_notification_multi_hop→ 1 passed (integration, 16.9 s after the test-contract wasm compile).cargo fmt --checkandcargo clippy -p freenet --lib --tests -- -D warningsclean.Fixes
Closes #4111. Follow-up: #4147 (wrapper-contract E2E asserting on a stable cause string).