Skip to content

raw_handle_debt ratchet counts across_*(|| ()) no-ops as conversions (~17 sites on main) #9152

Description

@proggeramlug

scripts/raw_handle_debt.py counts a conversion as done when the code calls RuntimeHandle::across_* — but across_*(|| ()) is a no-op wrapper that is byte-identical to the bare read it replaced. There are currently 17 such sites on main, across a dozen files, so the ratchet's headline count overstates how much custody has actually been established.

The mechanism

pub fn across_mut<T, R>(&self, f: impl FnOnce() -> R) -> (R, *mut T) {
    let result = f();
    (result, self.get_raw_mut_ptr::<T>())
}

With f = || () that is exactly self.get_raw_mut_ptr::<T>(). The point of across_* is that the allocating call goes inside the closure, so the pointer you get back is refreshed after it. An empty closure refreshes across nothing.

The helper's own doc comment already says as much:

It is not a proof. It cannot stop you reading the pointer before the call and holding that copy yourself … What it does is make the correct shape shorter than the incorrect one and give the ratchet in scripts/raw_handle_debt.py something to count down.

Where they are

crates/perry-runtime/src/array/iter_object.rs:1
crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs:1
crates/perry-runtime/src/collection_iter_object.rs:2
crates/perry-runtime/src/dyn_eval/tests.rs:2
crates/perry-runtime/src/intl/list_relative_plural.rs:1
crates/perry-runtime/src/map.rs:1
crates/perry-runtime/src/node_vm.rs:1
crates/perry-runtime/src/object/native_call_method.rs:3
crates/perry-runtime/src/object/object_ops/define_property.rs:1
crates/perry-runtime/src/object/reflect_support.rs:2
crates/perry-runtime/src/string/iter_object.rs:1
crates/perry-runtime/src/typedarray/construct.rs:1

Examples: array/iter_object.rs:86, collection_iter_object.rs:65, intl/list_relative_plural.rs:820, object/field_set_by_name/fast_paths.rs:230.

Why it matters

The failure direction is green. A module whose bare reads were all converted this way reports as clean while having exactly the custody it started with — and the ratchet then locks it at that count, so the next person "cannot" regress a file that was never fixed. This is the gate-can't-fail hazard from CLAUDE.md, one level down: the gate runs, but its subject is a syntactic shape rather than the invariant.

To be clear about scope: most of these are probably harmless — a read whose value is consumed immediately, with nothing allocating in between, genuinely has nothing to refresh across. The problem is that the ratchet cannot distinguish that case from a real custody boundary, so it counts both as progress.

Suggested fix

Make across_* reject the empty closure, so the shape has to be honest:

  • Cheapest: have raw_handle_debt.py match across_[a-z]*(\s*\|\|\s*\(\)) and count those as unconverted — they are. That immediately re-surfaces 17 sites for triage without touching any code.
  • Better: for reads that genuinely need no refresh, use (or add) a with_*_ptr-style scoped accessor and reserve across_* for real post-call reloads, so the two intents are distinguishable at a glance.

Found while auditing #9103 (where a first-pass fix introduced four fresh ones and I rejected them) and again while rebasing #9140. Filing because I initially read this as one author's shortcut and it is not — it is an established idiom that the tooling actively rewards.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions