Skip to content

Rust: an update after a field read of the field it replaces builds, and the read moves - #1438

Merged
jasisz merged 1 commit into
mainfrom
fix/rust-update-after-field-move
Sep 25, 2026
Merged

jasisz merged 1 commit into
mainfrom
fix/rust-update-after-field-move

Conversation

@jasisz

@jasisz jasisz commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Porting btc-listener onto main hit E0382 in generated Rust:

progress = flight.progress
Flight.update(flight, progress = dialledOn(progress)?)

inside tended, a member of a mutual tail-call group, became

let progress @ _ = flight.progress;
__MutualTco1::Turning({ let mut __updated = flight; __updated.progress = ...; __updated }, to)

Root cause

Two gaps, both on the tail-call paths only:

  • The let chains of a self tail call (emit_mir_tco_body) and of a mutual tail-call arm (emit_mir_trampoline_body) emitted each binding value raw. A field read there always moved the field out, whatever field_moves said, where every other body goes through emit_mir_binding_value and clones a read that is not movable.
  • Mutual tail-call arms never called apply_field_moves, so moved_roots was empty and the update of Move record fields out at the record's last use in generated Rust #1428 took the "nothing was moved out" path, moving the whole record.

The same raw move also broke a loop that reads a field in a let and later returns or updates the whole record (progress = flight.progress then true -> flight); that shape failed before #1428 too.

Fix

  • Loop and trampoline lets own their value through the same emit_mir_binding_value as other bindings (a bare Int binding and a discarded value are unchanged).
  • Trampoline arms apply the field-move facts. Their params are bound by value, so a movable read moves; an invariant (rc) param stays borrowed and is never moved.
  • own_param: a local bound by a let directly to a movable field read counts as owned. The provenance table holds copies of the binding values, whose addresses the movable set cannot recognise, so these slots are collected from the body itself. Without it f(progress) kept f on a borrowed Map that it cloned before inserting, while f(flight.progress) graduated.

Result on the btc shapes: let progress @ _ = flight.progress; Flight { progress: dialledOn(progress)?, ..flight } and Flight { progress: dialledOn(flight.progress)?, ..flight }, both without a clone.

Tests

  • rust_work_spec::an_update_after_a_field_read_of_the_replaced_field_builds_and_moves with fixture tests/fixtures/rust_update_after_field_move: plain bodies, a self loop that reads a field and hands on the whole record, and a mutual pair (let form and inline form). It checks the emitted moves (no .clone() of the moved field, grow takes its Map by value, no __updated = flight after a partial move), builds with cargo and compares with the VM.
  • Every entry under examples/, tests/fixtures and self_hosted/ with a main compiled to Rust and cargo checked: 151 pass, none fails with a move error. The 6 that fail (grok_s_language, http_server, 4 capability-manifest fixtures) fail the same way with the main binary (E0277/E0308, unrelated).
  • btc-listener HEAD, and HEAD with tended rewritten to both update forms, pass cargo check.
  • Self-host regenerated: three trampoline arms now move arm.body instead of cloning it.
  • lib tests, rust_work_spec, own_param_graduation, own_param_soundness, rust_codegen_regression, compile_spec, shared_update_spec, verify_handle_params_spec, fmt, clippy.

…ual tail calls

A let in the body of a self tail call or of a mutual tail-call arm emitted
its value as it was, so a field read there always moved the field out of
its record. Mutual tail-call arms also ran without the field-move facts, so
an update after such a read did not know the record was partially moved:

    progress = flight.progress
    Flight.update(flight, progress = f(progress))

became `let progress = flight.progress; { let mut __updated = flight; ... }`,
which rustc rejects (E0382). A loop that read a field in a let and later
returned the whole record failed the same way.

These lets now own their value like every other binding: the field moves
where the field-move analysis allows it and is cloned otherwise. Mutual
tail-call arms apply that analysis too, so the update keeps the other
fields with `..flight`, and `f(flight.progress)` inside the update moves
the field instead of cloning it.

A local bound by a let to a movable field read now counts as owned for
own_param, so `f(progress)` hands `f` a Map it can update in place, the
same as `f(flight.progress)`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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