Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7457-per-module-raw-handle-ceilings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Raw-handle debt is now per-module, so new runtime code is clean by construction.** A single global total cannot make the Layer 3 discipline non-optional: it is blind to debt moving between modules, it lets a brand-new file start dirty as long as something else got cleaner, and it has no finish line. Since 595 of the 705 runtime modules already carry zero bare reads, the ratchet is inverted — `scripts/raw_handle_debt_files.txt` lists the 110 modules *permitted* to carry debt with a per-module ceiling, and everything else must be at zero. An unlisted module with any bare read fails, a listed module over its ceiling fails, and a listed module that reaches zero fails until its line is deleted (the same matching-nothing rule as the dominance allowlist, which is what makes a cleanup permanent). Per-module checks run before the global total so the diagnostic names the file and the fix. All three rules plus the clean case are asserted in `--self-test`, which fails when the checker is stubbed out. Seeded at today's 1002 sites, so it is green on merge and changes no runtime code. (#7457)
101 changes: 100 additions & 1 deletion scripts/raw_handle_debt.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,49 @@ def count():
total += n
return total, per_file

FILES = ROOT / "scripts" / "raw_handle_debt_files.txt"


def load_ceilings():
"""`{path: ceiling}` from the per-module file. Comments and blanks ignored."""
out = {}
for line in FILES.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
n, path = line.split(None, 1)
out[path.strip()] = int(n)
return out


def check_per_module(per_file):
"""Per-module rules. Returns a list of human-readable violations.

All three directions are closed, and the third is the one that makes the
list shrink rather than drift: an entry that no longer matches anything is
a FAILURE, exactly as in `gc_root_dominance_allowlist.json`. Without it a
cleaned module keeps its line forever and quietly re-permits the debt the
next time someone edits that file.
"""
ceilings = load_ceilings()
bad = []
for path, n in sorted(per_file.items()):
if path not in ceilings:
bad.append(
f"{path}: {n} bare read(s) in a module with no ceiling. New code must "
f"use RuntimeHandle::across_{{mut,const,nanbox}}; see #7341."
)
elif n > ceilings[path]:
bad.append(f"{path}: {n} bare reads exceeds its ceiling of {ceilings[path]}")
for path, ceiling in sorted(ceilings.items()):
if path not in per_file:
bad.append(
f"{path}: ceiling of {ceiling} matches nothing -- the module is clean "
f"(or gone). DELETE its line so the cleanup cannot be undone."
)
return bad


def self_test():
"""Guard the gate against its own regressions.

Expand Down Expand Up @@ -75,7 +118,30 @@ def self_test():
if total == 0 or not per_file:
print("self-test FAILED: counted zero sites -- the walk is broken")
return 1
print(f"self-test ok ({total} sites across {len(per_file)} files)")
# The per-module rules are what make the discipline non-optional, so each
# of the three directions is asserted rather than trusted. A rule that
# silently stops firing is worse than no rule: it reads as "every other
# module is locked at zero" while locking nothing.
saved = globals()["load_ceilings"]
globals()["load_ceilings"] = lambda: {"a.rs": 2, "gone.rs": 1}
try:
checks = [
("unlisted module carrying debt", {"a.rs": 2, "new.rs": 1}, "no ceiling"),
("listed module over its ceiling", {"a.rs": 3, "gone.rs": 1}, "exceeds its ceiling"),
("cleaned module still listed", {"a.rs": 2}, "matches nothing"),
]
for label, per, needle in checks:
if not any(needle in v for v in check_per_module(per)):
print(f"self-test FAILED: rule did not fire: {label}")
return 1
if check_per_module({"a.rs": 2, "gone.rs": 1}):
print("self-test FAILED: the clean case reported a violation")
return 1
finally:
globals()["load_ceilings"] = saved

print(f"self-test ok ({total} sites across {len(per_file)} files); "
f"all three per-module rules fire, clean case silent")
return 0

def main():
Expand All @@ -89,13 +155,43 @@ def main():
print("the ratchet only goes down; convert sites to across_* instead")
return 1
BASELINE.write_text(f"{total}\n")
# Rewrite the per-module ceilings too, preserving the header. Entries
# that reached zero simply do not come back -- rule 3.
header = []
for line in FILES.read_text(encoding="utf-8").splitlines():
if line.startswith("#") or not line.strip():
header.append(line)
else:
break
FILES.write_text(
"\n".join(header) + "\n"
+ "".join(f"{n} {p}\n" for p, n in sorted(per_file.items()))
)
print(f"baseline set to {total}" + (f" (was {prev})" if prev is not None else ""))
print(f"per-module ceilings rewritten: {len(per_file)} entries")
return 0
if not BASELINE.exists():
print(f"no baseline; run --update. current={total}")
return 1
prev = int(BASELINE.read_text().split()[0])
print(f"bare raw-handle reads: {total} (baseline {prev})")

# Per-module rules run FIRST and unconditionally. They are strictly more
# specific than the total -- "symbol.rs exceeds its ceiling of 1" names the
# file and the fix, where "the total rose" does not -- and if the total
# check returned first the specific diagnostic would never be printed for
# the most common failure (a module gaining a read).
module_violations = check_per_module(per_file)
if module_violations:
print(f"::error::per-module raw-handle rules: {len(module_violations)} violation(s)")
for b in module_violations:
print(f" {b}")
print("Use RuntimeHandle::across_{mut,const,nanbox} -- it runs the")
print("allocating call and returns the post-collection address, so the")
print("stale pointer is never bound. See #7341 and the header of")
print("scripts/raw_handle_debt_files.txt.")
return 1

if total > prev:
print(f"::error::raw-handle debt rose {prev} -> {total}")
print("Use RuntimeHandle::across_{mut,const,nanbox} -- it runs the")
Expand All @@ -106,6 +202,9 @@ def main():
return 1
if total < prev:
print(f"debt fell by {prev - total}; run --update to lock it in")

print(f"per-module: {len(per_file)} module(s) within ceilings; every other "
f"runtime module is locked at zero")
return 0

if __name__ == "__main__":
Expand Down
135 changes: 135 additions & 0 deletions scripts/raw_handle_debt_files.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Modules permitted to carry raw-handle debt, with their current ceiling.
#
# THE POINT OF THIS FILE IS THAT IT IS NOT A TOTAL. A single global number lets
# debt move between modules unnoticed, and lets a brand-new file start dirty as
# long as something else got cleaner. The rules here are per-module and all
# three directions are closed:
#
# 1. A file NOT listed here must have ZERO bare reads. New code is therefore
# clean by construction -- `RuntimeHandle::across_{mut,const,nanbox}` is
# the only way in, which is what "non-optional" means for #7341's
# discipline (engine plan layer 3).
# 2. A listed file must not EXCEED its ceiling.
# 3. A listed file that reaches ZERO must be DELETED from this list. An entry
# that matches nothing FAILS the build -- the same rule as
# gc_root_dominance_allowlist.json -- so the list can only shrink and
# cleaning a module is permanent.
#
# Lower a ceiling whenever you convert a pair; never raise one. If a change
# genuinely needs a new bare read in an unlisted file, the honest move is to
# convert a pair elsewhere and say so in the PR, not to add a line here.
#
# 595 of 705 runtime modules were already clean when this was seeded, so the
# rule above covers 84% of the crate on day one.
#
# Format: <count> <path>
1 crates/perry-runtime/src/array/from_concat.rs
5 crates/perry-runtime/src/array/header.rs
6 crates/perry-runtime/src/array/indexing.rs
2 crates/perry-runtime/src/array/iter_methods.rs
4 crates/perry-runtime/src/array/push_pop.rs
14 crates/perry-runtime/src/array/sort.rs
18 crates/perry-runtime/src/async_hooks.rs
5 crates/perry-runtime/src/atomics.rs
7 crates/perry-runtime/src/builtins/console.rs
15 crates/perry-runtime/src/builtins/globals.rs
5 crates/perry-runtime/src/bun_ffi/dlopen.rs
3 crates/perry-runtime/src/child_process/v8_serde.rs
5 crates/perry-runtime/src/dns.rs
6 crates/perry-runtime/src/embedded.rs
8 crates/perry-runtime/src/error.rs
16 crates/perry-runtime/src/event_target.rs
3 crates/perry-runtime/src/frame.rs
3 crates/perry-runtime/src/fs/dir_glob_watch/watch.rs
3 crates/perry-runtime/src/gc/tests/copying.rs
4 crates/perry-runtime/src/gc/tests/helper_stores.rs
47 crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs
3 crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs
1 crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs
10 crates/perry-runtime/src/gc/tests/runtime_roots/transient_handles.rs
11 crates/perry-runtime/src/json/replacer.rs
26 crates/perry-runtime/src/json/reviver.rs
3 crates/perry-runtime/src/json/stringify.rs
1 crates/perry-runtime/src/json/stringify_scalars.rs
22 crates/perry-runtime/src/json_tape.rs
27 crates/perry-runtime/src/map.rs
2 crates/perry-runtime/src/module_require.rs
7 crates/perry-runtime/src/node_stream/async_iterator.rs
2 crates/perry-runtime/src/node_stream_dispatch.rs
8 crates/perry-runtime/src/node_stream_iter_helpers.rs
27 crates/perry-runtime/src/node_stream_pipeline.rs
7 crates/perry-runtime/src/node_stream_tests.rs
10 crates/perry-runtime/src/node_stream_tests_extra.rs
4 crates/perry-runtime/src/node_submodules/mod.rs
4 crates/perry-runtime/src/node_submodules/test.rs
33 crates/perry-runtime/src/object/alloc.rs
1 crates/perry-runtime/src/object/array_object_ops.rs
2 crates/perry-runtime/src/object/bigint_dispatch.rs
5 crates/perry-runtime/src/object/class_registry/construct.rs
2 crates/perry-runtime/src/object/delete_rest.rs
12 crates/perry-runtime/src/object/descriptors.rs
2 crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
3 crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs
11 crates/perry-runtime/src/object/field_set_by_name/tail.rs
2 crates/perry-runtime/src/object/global_this/ctor_thunks.rs
9 crates/perry-runtime/src/object/global_this/populate.rs
9 crates/perry-runtime/src/object/global_this_webassembly.rs
1 crates/perry-runtime/src/object/mod.rs
3 crates/perry-runtime/src/object/namespace_create.rs
1 crates/perry-runtime/src/object/native_call_method/object_proto.rs
4 crates/perry-runtime/src/object/native_call_method/primitive_methods.rs
10 crates/perry-runtime/src/object/native_call_method/string_methods.rs
19 crates/perry-runtime/src/object/native_module/async_hooks_exports.rs
26 crates/perry-runtime/src/object/native_module/callable_exports.rs
4 crates/perry-runtime/src/object/native_module.rs
6 crates/perry-runtime/src/object/object_literal_ops.rs
3 crates/perry-runtime/src/object/object_ops/define_property.rs
3 crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs
4 crates/perry-runtime/src/object/object_ops/from_entries.rs
5 crates/perry-runtime/src/object/object_ops/keys_array.rs
6 crates/perry-runtime/src/object/polymorphic_index.rs
4 crates/perry-runtime/src/object/reflect_support.rs
3 crates/perry-runtime/src/object/spill.rs
2 crates/perry-runtime/src/object/typed_array_define.rs
4 crates/perry-runtime/src/os.rs
14 crates/perry-runtime/src/os_network.rs
3 crates/perry-runtime/src/os_process_streams.rs
13 crates/perry-runtime/src/plugin.rs
12 crates/perry-runtime/src/process/env_misc.rs
10 crates/perry-runtime/src/promise/microtasks.rs
1 crates/perry-runtime/src/promise/native_async.rs
3 crates/perry-runtime/src/promise/rejection.rs
8 crates/perry-runtime/src/promise/then.rs
1 crates/perry-runtime/src/proxy/put_value.rs
8 crates/perry-runtime/src/proxy.rs
17 crates/perry-runtime/src/regex/exec.rs
30 crates/perry-runtime/src/regex/exec_array.rs
14 crates/perry-runtime/src/regex/match_all.rs
24 crates/perry-runtime/src/regex/match_string.rs
7 crates/perry-runtime/src/regex/replace_expand.rs
3 crates/perry-runtime/src/regex/replace_fn.rs
9 crates/perry-runtime/src/regex.rs
41 crates/perry-runtime/src/set.rs
2 crates/perry-runtime/src/string/append.rs
7 crates/perry-runtime/src/string/concat.rs
1 crates/perry-runtime/src/string/mod.rs
10 crates/perry-runtime/src/string/split.rs
2 crates/perry-runtime/src/symbol/iterator.rs
1 crates/perry-runtime/src/symbol.rs
27 crates/perry-runtime/src/thread.rs
7 crates/perry-runtime/src/timer.rs
1 crates/perry-runtime/src/typed_feedback.rs
4 crates/perry-runtime/src/typedarray/construct.rs
2 crates/perry-runtime/src/typedarray/transform.rs
4 crates/perry-runtime/src/url/node_compat.rs
7 crates/perry-runtime/src/url/search_params.rs
12 crates/perry-runtime/src/util_debuglog.rs
20 crates/perry-runtime/src/util_parse_args.rs
49 crates/perry-runtime/src/util_promisify.rs
10 crates/perry-runtime/src/v8.rs
6 crates/perry-runtime/src/value/dyn_index.rs
6 crates/perry-runtime/src/value/dynamic_arith.rs
3 crates/perry-runtime/src/value/to_string.rs
19 crates/perry-runtime/src/wasi.rs
7 crates/perry-runtime/src/weakref.rs
23 crates/perry-runtime/src/webassembly.rs
Loading