Skip to content

Address captures by content, not by the process that wrote them (#201) - #416

Merged
milyin merged 4 commits into
mainfrom
content-addressed-captures
Aug 15, 2026
Merged

Address captures by content, not by the process that wrote them (#201)#416
milyin merged 4 commits into
mainfrom
content-addressed-captures

Conversation

@milyin

@milyin milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Closes #201.

The leak

#[prebindgen] named each capture {group}_{pid}_{thread}.jsonl, so every rustc process wrote another full copy of the same records. Nothing removes them: init_prebindgen_out_dir() clears the directory only when Cargo re-runs the build script, which happens on a source change — and rustc/rustdoc run far more often than that.

Doctests make it unbounded, because Cargo never caches them. With no edit at all:

before this PR
cargo build -p example-flat 2 files 2
cargo test -p example-flat #1 6 47
#2 8 47
#3 10 47
#4 12 47
#5 14 47

(The jump to 47 is the layout change — one file per record instead of one per process. The point is the column: it stops moving.)

The fix: name the file after the data

{OUT_DIR}/prebindgen/{group}/{name}_{digest}.jsonl

The digest covers the record's serialized form. Any compilation that captures an item computes the same path and writes the same bytes, so repeated compilations rewrite one file instead of accumulating a copy each. The file set is a function of the source, not of build history.

This is what makes the layout loss-proof, and it is the main argument for it. The previous approach to bounding growth was to derive a key for the writer — the compilation unit — and let each writer reset its own file. That only holds while the key is exactly as fine-grained as the unit, and getting it wrong loses records rather than leaking files. Deriving the name from the data removes the question: the name determines the contents, so two writers either write identical bytes to one path or different bytes to different paths. There is no third case where one compilation resets another's records.

It also stops depending on the compiler's command line, which the unit-keyed approach had to parse (-C metadata, and an argument filter for rustdoc). Nothing here reads argv, so a build driven by a compiler that doesn't distinguish invocations that way is not a special case.

Why the digest is load-bearing

Not decoration — it is what keeps the "name determines contents" property true:

  • #[cfg(test)] fn f and the crate's own fn f are different records with one name, and their units compile in parallel under cargo test.
  • macOS and Windows fold filename case, and Rust is happy to have both struct Foo and fn foo.

The readable part is capped at 48 characters and forced to ASCII, since Rust identifiers admit characters filesystems normalize differently.

Publishing

Each record is serialized, written to a temporary file in the destination directory, and renamed over the target. A reader sees a complete file or no file; a lost race costs nothing because the loser wrote identical bytes. An existing non-empty file at the path is left alone. Write failures are now reported at the item as compiler diagnostics rather than a bare compile_error!.

Groups become directories

A group is now a subdirectory rather than a filename prefix. That fixes a latent bug: the flat layout recovered the group as the prefix up to the first _, so #[prebindgen("my_group")] was discovered as group my and items_in_groups(&["my_group"]) returned nothing.

  • Group names are validated at the item (ASCII letters, digits, _, -), since they now name a directory.
  • Source reads the group directory and still reads the flat layout, so a capture directory written by an older macro is not orphaned.
  • init_prebindgen_out_dir() now removes directories as well as files. Leaving them would keep records for items the new revision renamed or deleted — the one way this layout could go stale.

Scenarios

who compiles before now
cargo check / build / test units one file per process, forever same files rewritten
doctest rustdoc (never cached) +2 files per run, unbounded same files rewritten
cargo doc rustdoc one file per process same files rewritten
units with different item sets (#[cfg(test)]) union across files union across files, unchanged
parallel units, same item two files one file, identical bytes
parallel units, same name, different item two files two files (digest differs)
a compiler that doesn't distinguish its invocations one file per process same files rewritten

Verification

  • Growth: build, then test ×5, test --doc alpha, test --doc beta, clippy --all-targets, doc — the file set in each build-hash directory is byte-identical after every command (47 files: 44 default + 3 structs).
  • No loss across units: with a temporary #[cfg(test)] #[prebindgen] fn test_only_probe() in example-flat, cargo test --lib followed by cargo build leaves 48 files and the probe survives. (Under the flat layout this is the case that a unit-keyed name gets wrong when the key is too coarse.)
  • Concurrency: 32 threads racing on one path leave exactly one complete file and no temporaries.
  • Case folding: struct Foo and fn foo produce names that differ after to_lowercase().
  • Reader: group directories, a group name containing _, the legacy flat layout, and non-capture entries (crate_name.txt, stray files) are covered by unit tests.
  • cargo test --all --all-features, cargo clippy --all-targets --all-features -- --deny warnings, cargo fmt --check, on 1.85.0 (MSRV) and 1.97.1.
  • examples/regen-check.sh: committed generated output is byte-identical, i.e. consumers produce the same bindings from the new layout.

Relationship to #396 and #398

  • Stop leaking a JSONL file per rustc invocation (#201) #396 fixes the same issue by keying the file to the compilation unit, parsed out of rustc's -C metadata with an argument filter for rustdoc. It is correct as far as it goes — and it carries a reproduced record-loss regression fix for the metadata list. This PR supersedes that approach: it deletes the need for a unit key at all, so Stop leaking a JSONL file per rustc invocation (#201) #396's unit_id, metadata parsing and runtime-option deny-list have no counterpart here. Either can land; they should not both.
  • Add concurrency-safe stale capture cleanup #398 (cargo prebindgen clean) is unaffected and still wanted: it addresses stale target/*/build/<pkg>-<hash>/ directories, which are Cargo's and which this PR deliberately does not touch. Note the counts above are per build-hash directory — clippy and doc still mint their own.

rand is dropped from the workspace, since nothing generates names any more.

`#[prebindgen]` named each capture `{group}_{pid}_{thread}.jsonl`, so every
rustc process created another full copy of the same records. Nothing removes
them: the build script clears the directory only when Cargo re-runs it, which
happens on a source change — while rustc and rustdoc run far more often than
that. Doctests are the extreme case, since Cargo never caches them: five
`cargo test -p example-flat` runs with no edit in between took the directory
from 2 files to 14, and it never stops (#201).

Name each capture after the record instead: `{group}/{name}_{digest}.jsonl`,
where the digest covers the record's serialized form. Any compilation that
captures an item computes the same path and writes the same bytes, so repeated
compilations rewrite one file rather than adding a copy each.

Deriving the name from the data rather than from the writer also makes the
layout loss-proof by construction: the name determines the contents, so two
writers either write identical bytes to one path or different bytes to
different paths. There is no third case where one compilation resets another's
records — which is why the digest is load-bearing and not decoration. It keeps
`#[cfg(test)] fn f` apart from the crate's own `fn f`, whose units compile in
parallel, and keeps `struct Foo` apart from `fn foo` on the case-folding
filesystems of macOS and Windows.

Records are published by writing a temporary file in the destination directory
and renaming it over the target, so a reader sees a complete file or no file,
and a lost race costs nothing. A capture that already exists is left alone.
Write failures are now reported at the item as compiler diagnostics instead of
a bare `compile_error!`.

Groups become directories, which is what lets a group name contain `_`: the
flat layout spelled the group as the file-name prefix up to the first
underscore, so `#[prebindgen("my_group")]` was discovered as group `my`. Group
names are validated at the item, since they now name a directory. `Source`
reads the group directory and still reads the flat layout, so a capture
directory written by an older macro is not orphaned.

`init_prebindgen_out_dir()` removes directories as well as files: leaving them
would keep records for items the new revision renamed or deleted.
@milyin

milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Review outcome: I would not merge this revision yet because the new group-directory representation does not preserve valid group identities cross-platform.

[P1] Encode group directories independently of filesystem name semantics. validate_group accepts uppercase ASCII, so #[prebindgen("Foo")] and #[prebindgen("foo")] are distinct groups in the API, but .join(group) maps them to the same directory on the default macOS filesystem and on Windows. I reproduced the underlying behavior on this checkout: after creating Foo, creating sibling foo fails with File exists. Records from both groups therefore land in one directory, discover_groups retains only whichever casing was created first, and items_in_groups either returns nothing for the other spelling or exposes the merged group. This silently changes generated bindings by host filesystem. The same validator also accepts Windows device names such as CON, PRN, AUX, NUL, COM1, and LPT1, for which create_dir_all fails on Windows.

Please encode the group into a portable collision-free component (for example, a prefixed reversible lowercase hex encoding of the original bytes) or otherwise reject every alias/reserved case, and add coverage for case-only group pairs plus Windows device names. The existing case-fold test covers record filenames, but not the new directory level where this collision occurs.

The focused proc-macro tests (4 + 4 doctests), Source tests (4), JSONL tests (6), and git diff --check all pass locally; all six current CI jobs are also green. I did not find another actionable issue in the changed paths.

— Codex (GPT-5)

`.join(group)` put `#[prebindgen("Foo")]` and `#[prebindgen("foo")]` in one
directory on macOS and Windows, merging two groups the API keeps apart:
`discover_groups` kept whichever casing was created first, and
`items_in_groups` then returned nothing for the other spelling or exposed the
merged group — generated bindings varying by host filesystem. The validator
also accepted `CON`, `PRN`, `NUL`, `COM1` and friends, which Windows refuses as
a path component outright.

Encode the group instead of restricting it: a directory is now
`{digest(group)}_{group}`, the same rule the record files already follow —
a digest carries the identity, a sanitized spelling carries the readability.
Distinct group names get distinct directories on any filesystem, and no
component can be a bare device name, a path separator, `..`, or a
normalization-dependent spelling. `validate_group` goes away with the problem
it was guarding: any string literal is a usable group name again.

The digest is not reversible, so the group's exact name is published in
`group.txt` beside its records, before the group's first record, and
`discover_groups` reads it there. A directory holding captures without one is
a damaged capture directory rather than an unnamed group, and says so instead
of dropping records silently.

Both halves of the layout now live in `prebindgen::layout`, computed by the
proc-macro when writing and by `Source` when reading, with the filesystem
hazards they defend against documented in one place. `write_record_file`
becomes `publish_file`, since the group name file goes through it too.

Covered by tests: case-only group pairs, Windows device names, separators and
traversal, non-ASCII and over-long names, and — on this checkout's APFS — a
real `structs` / `Structs` pair building side by side in example-flat.
@milyin

milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Fixed in f0883a4. You were right that the directory level was unguarded — and the fix is to stop validating the group name and encode it instead, the same rule the record files already followed.

A group directory is now {digest(group)}_{group}: a digest carrying the identity, a sanitized spelling carrying the readability. That closes all four ways a group name was unrepresentable, rather than rejecting the ones we thought of:

  • Case foldingFoo and foo have different digests, and the names differ after to_lowercase(), so they differ on a case-insensitive filesystem too.
  • Reserved names — every component starts with 16 hex digits and _, so none is ever a bare CON / PRN / AUX / NUL / COM1LPT9, with or without an extension.
  • Separators and traversal"a/b", "..", "a:b", "a\0b" all sanitize to one contained component.
  • Unicode normalization — the readable half is reduced to ASCII, so a name never depends on how the host stores it.

validate_group is gone with the problem it guarded: any string literal is a usable group name again.

The digest is not reversible, so the exact name is published in group.txt beside the records, before the group's first record, and discover_groups reads it there. A directory holding captures without one is a damaged capture directory, not an unnamed group, and now says so rather than dropping records silently.

Both halves of the layout moved into prebindgen::layout, computed by the proc-macro when writing and by Source when reading, with the filesystem hazards documented in one place.

Coverage added at the directory level, as asked:

  • groups_differing_only_in_case_get_different_directories and group_directories_are_never_windows_device_names (all eight device names) in layout
  • groups_differing_only_in_case_stay_apart and a_group_may_be_named_anything_a_string_literal_can_hold (CON, NUL, COM1, LPT1, my_group, a/b, .., grüppe, "") in Source
  • captures_without_a_group_name_are_not_silently_dropped
  • group_directories_are_one_contained_component, distinct_group_names_get_distinct_directories

And end-to-end on this checkout's APFS, with a temporary #[prebindgen("Structs")] item added to example-flat beside its existing structs group:

2dda467753a1b327_structs -> group structs : 3 records
4eb7ff81b5f63ce1_default -> group default : 44 records
6c5d37db80ca4b65_Structs -> group Structs : 1 records

Both spellings build side by side and discover_groups reports both exactly.

Re-verified: cargo test --all --all-features (33 in prebindgen), strict all-target/all-feature Clippy, rustfmt, git diff --check, on 1.85.0 and 1.97.1; examples/regen-check.sh byte-identical; and the growth check unchanged — build, test x3, test --doc alpha, test --doc beta leave a byte-identical capture tree every time.

The digest that made group directories collision-free was not reversible, so
the group's exact name had to be published beside its records and read back
from there. A reversible encoding carries the name in the directory itself.

`g_` plus the group with only `a-z`, `0-9` and `_` verbatim and every other
byte as `-XX`. Escaping to `-XX` rather than `_XX` keeps the ordinary case
readable, since `_` is the character group names actually contain: `default`
and `structs` stay `g_default` and `g_structs`, while `Structs` is `g_-53tructs`
and `my_group/../CON` is `g_my_group-2f-2e-2e-2f-43-4f-4e`.

The four hazards close exactly as before, now by construction rather than by
hashing: the encoded form holds no uppercase, so case folding cannot merge two
encodings; no separator or dot, so nothing escapes the capture directory or
ends in one; no non-ASCII, so nothing depends on how the host normalizes; and
the `g_` prefix keeps `CON` and friends off Windows' device list.

Removing the name file also removes a way for a directory and the name it
claims to disagree — a decoded name cannot be stale or missing. `Source` still
refuses to walk past a directory that holds captures it cannot decode, rather
than dropping records silently.

A group name is now bounded only by what a path component can hold: one that
encodes past 255 bytes is reported at the item.
@milyin

milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Follow-up in 20c210d: the group directory now uses a reversible encoding, so the group.txt sidecar is gone — the directory carries the name itself.

g_ plus the group with only a-z, 0-9 and _ verbatim, every other byte as -XX. Escaping to -XX rather than _XX keeps the ordinary case readable, since _ is the character group names actually contain. On disk, from an example-flat build with two temporary probes added:

g_default
g_structs
g_-53tructs                        <- group "Structs", beside "structs" on APFS
g_my_group-2f-2e-2e-2f-43-4f-4e    <- group "my_group/../CON"

The four hazards now close by construction rather than by hashing: the encoded form holds no uppercase (case folding cannot merge two encodings), no separator or dot (nothing escapes the capture directory or ends in one), no non-ASCII (nothing depends on host normalization), and the g_ prefix keeps CON and friends off Windows' device list.

Dropping the name file also removes a way for a directory and the name it claims to disagree: a decoded name cannot be stale or missing. Source still refuses to walk past a directory holding captures it cannot decode, rather than dropping records silently. A group name is now bounded only by what a path component can hold — one encoding past 255 bytes is reported at the item.

New coverage in layout: a_group_name_survives_the_round_trip over the hostile corpus, every_char_survives_the_round_trip (every code point up to U+02FF plus astral samples), and only_directories_this_produced_decode. The case-fold, device-name and containment tests are unchanged and still pass, as does the on-disk structs / Structs pair.

Re-verified: 36 prebindgen tests, cargo test --all --all-features, strict all-target/all-feature Clippy, rustfmt, on 1.85.0 and 1.97.1; examples/regen-check.sh byte-identical; growth check unchanged — build, test x3, --doc alpha, --doc beta leave a byte-identical capture tree every time.

@milyin

milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Re-review at 20c210d4: the previous case-folding / Windows-device blocker is fixed. The reversible g_… encoding keeps Foo and foo separate on APFS, avoids reserved device names, contains separators, and round-trips the supported group strings correctly.

One remaining issue:

[P2] Require a canonical encoded directory before accepting it as a group. decode_group_dir_name currently accepts spellings that group_dir_name can never produce. For example, g_-61 decodes to group a, although that group canonicalizes to g_a; uppercase hex such as g_-4A is also accepted even though the encoder emits lowercase. If g_-61 contains captures, discover_groups inserts a, but read_group("a") looks only in g_a, so the files are silently dropped—the exact damaged-directory failure the new holds_captures guard is intended to prevent. A non-UTF-8 directory name on Unix also bypasses that guard because the loop continues when to_str() fails.

Please accept a decoded name only when group_dir_name(&decoded) == dir_name, and run the holds_captures damage check for non-UTF-8 directory names too. A focused regression can rename canonical g_a to g_-61, leave its JSONL inside, and assert that discovery reports damage rather than returning group a with no records.

Verification: all 36 prebindgen library tests, all 4 proc-macro doctests, and git diff --check pass locally; all six CI jobs on this head are green. I found no other actionable issue in the updated paths.

— Codex (GPT-5)

The encoding admits redundant spellings — `g_-61` decodes to `a` just as `g_a`
does, and `-4A` names the same byte as `-4a` — and decoding one was worse than
rejecting it. Discovery would report group `a` while `group_dir_name` sends
`read_group` to `g_a`, so the records in `g_-61` were dropped with nothing to
show for it: exactly the damage the capture check exists to catch, walking
straight past it.

Decode only when the name re-encodes to the directory it came from.

A directory name that is not UTF-8 skipped that check too: discovery bailed
before deciding anything, captures and all. A group directory is ASCII, so
such a name is never one — but it still has to face the damage check rather
than be stepped over.

Regressions for both, plus a property test that every hostile group name
decodes from its canonical spelling and from no other. The non-UTF-8 case is
Linux-only: APFS refuses to create such a name at all, which is why macOS
cannot reach it.
@milyin

milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Both fixed in ed799d2.

Canonical spelling required. decode_group_dir_name now returns a name only when it re-encodes to the directory it came from:

let decoded = String::from_utf8(bytes).ok()?;
(group_dir_name(&decoded) == dir_name).then_some(decoded)

That rejects every redundant spelling in one rule rather than enumerating them — g_-61, g_-4A, g_-5f all decode to bytes but not to themselves. You were right that accepting one was worse than rejecting it: discovery reported a while read_group looked in g_a, so the records walked straight past the damage check they should have tripped.

Non-UTF-8 directory names. Discovery no longer continues before deciding. The name is now an Option<&str> carried into the branch, so a directory whose name is not UTF-8 — never a group directory, since those are ASCII — still faces holds_captures.

Regressions, both using your suggested shape:

  • captures_under_a_non_canonical_spelling_are_not_silently_dropped — writes group a, renames g_a to g_-61 with the JSONL inside, asserts discovery reports damage.
  • captures_under_a_non_utf8_directory_are_not_silently_dropped — renames a populated group directory to g_\xff\xfe, same assertion. Linux-only: APFS refuses to create such a name at all, which is why macOS cannot reach the case. Confirmed running green in CI on both toolchains:
build (1.85.0)  test api::source::tests::captures_under_a_non_utf8_directory_are_not_silently_dropped - should panic ... ok
build (stable)  test api::source::tests::captures_under_a_non_utf8_directory_are_not_silently_dropped - should panic ... ok
  • only_the_canonical_spelling_of_a_name_decodes — a property test over the hostile corpus: each name decodes from its canonical directory, and neither from the uppercased spelling nor from one with a redundant escape prepended.
  • only_directories_this_produced_decode extended with the three redundant forms above.

Re-verified: 37 prebindgen tests, cargo test --all --all-features, strict all-target/all-feature Clippy, rustfmt, on 1.85.0 and 1.97.1; examples/regen-check.sh byte-identical; growth check unchanged — build, test x3, --doc beta leave a byte-identical capture tree every time. All six CI jobs green.

@milyin

milyin commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Re-review at ed799d25: approved from my side; I found no remaining actionable findings.

The previous P2 is fixed correctly:

  • decode_group_dir_name now accepts only the canonical round-trip spelling, so redundant encodings such as g_-61, g_-4A, and g_-5f cannot make discovery name one group while reading from another directory.
  • Non-UTF-8 directory names no longer bypass the damage path; directories are classified before their names are decoded, and capture-bearing invalid directories are reported.
  • The focused regressions exercise both failure modes, including the Linux-only non-UTF-8 case.

I also rechecked the original cross-platform concern: case-only groups remain distinct, Windows device names are safely prefixed/encoded, separators and traversal are contained, Unicode is represented through ASCII byte escapes, and the encoding is reversible.

Local verification on this head: all 37 prebindgen library tests, all 4 proc-macro doctests, git diff --check, and a clean worktree. All six GitHub CI jobs are green.

— Codex (GPT-5)

@milyin
milyin merged commit 97cecda into main Aug 15, 2026
6 checks passed
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.

it seems that prebindgen generated files increasingly taking space

1 participant