Summary
Running componentize-py componentize twice with the same command and byte-identical
inputs can produce valid but byte-different WebAssembly components. This prevents use in
content-addressed build and artifact-verification pipelines.
I would like maintainer guidance on the shape of an opt-in reproducible-build mode before
opening the larger implementation PR. A small independent PR that makes WASI stub-adapter
emission deterministic is ready separately.
Reproduction
Using current main at aa3d6d1, Rust 1.95.0, WASI SDK 34, and the repository sandbox
example:
for output in first.wasm second.wasm; do
componentize-py \
-d sandbox.wit \
componentize \
--stub-wasi \
guest \
-o "$output"
done
shasum -a 256 first.wasm second.wasm
cmp first.wasm second.wasm
Expected: both files are byte-identical.
Actual: the files differ while remaining valid components.
Investigation
An experimental --reproducible implementation fixed or controlled all of the following:
- build-time secure and insecure randomness;
- build-time wall and monotonic clocks;
PYTHONHASHSEED;
- CPython allocator initialization with
PYTHONMALLOC=malloc_debug;
- bytecode cache writes;
- WASI stub-adapter map iteration order;
- metadata on generated helper, standard-library, bindings, and input trees.
Even with all of those controls, two current-main outputs still differed: one run produced
19,323,845 bytes, the other 19,325,725 bytes, with the first difference at component offset
9.
I then repeated the experiment with ambient user/system site-packages excluded and the
same input, generated bindings, bundled helpers, and standard library exposed through stable
host directories. The outputs still differed (292a9320... versus f35b0e6d...). Both had
the same 208-page memory and 1,710 data segments, but reconstructing linear memory from those
segments found roughly 4.1 million differing bytes across 141 pages.
The difference is localized more narrowly than an encoder or linker problem:
- both outputs contain 205 top-level component sections;
- 204 sections are byte-identical;
- only the first embedded core module differs;
- within that module, all type, import, function, table, global, export, and code sections are
byte-identical;
- only the memory snapshot/data section differs.
Additional two-run probes showed identical values for Python string hashes, random, wall and
monotonic clocks, process/thread identity, visible directory ordering, generated Symbols, and
the component immediately before pre-initialization. Full linear-memory fingerprints taken
inside the guest were identical after app import and after every do_init phase (exports, type
tables, constructors, environment, runtime hooks, argv, and full-generation GC).
However, immediately after call_init returned through the Component Model boundary, before
component-init-transform measured globals or memory, the linear-memory fingerprints differed.
Disabling either or both WASI adapter/libc reset calls did not change that result. Ordering the
internal component-init-transform maps, reusing stable-inode directories, collecting Python
free lists after return through a second no-argument guest call, and retaining the Rust-level
app_name/Symbols values also did not fix it.
This localizes the remaining nondeterminism to the generated canonical-ABI post-return path for
the large nested init(app-name, symbols, stub-wasi) -> result<_, string> call (or allocator
state changed by that path), rather than Python initialization, component encoding, or snapshot
measurement. The current-main update moved from wit-bindgen 0.53.1 to 0.61.0 and Wasmtime 46.0.1
to 48.0.0; the exact responsible layer still needs a minimal reduction.
The experiment deliberately did not use post-generation byte rewriting or a volatile-byte
allowlist.
Proposed direction
- Add a regression test that componentizes one fixture twice and compares the complete
bytes.
- Add an opt-in reproducible mode so existing build-time clock/random semantics do not
change by default.
- Give pre-initialization fixed clocks, random sources, Python hash seed, allocator state,
and bytecode behavior in that mode.
- Canonicalize generated directory metadata and provide input files through a staged or
virtualized tree. The implementation should not modify user source-file metadata.
- Keep output ordering deterministic throughout stub generation and component encoding.
- Add a reduced test around the init canonical-ABI boundary. Candidate fixes include capturing
memory before post-return cleanup, making that cleanup allocator-deterministic, or changing
the private init protocol so the large nested argument graph does not leave volatile allocator
state in the captured memory. Any fix must preserve matching allocator globals and memory.
Would the maintainers prefer this as one opt-in feature PR, or as smaller PRs after the
stub-ordering fix?
Additional context
This was found while building a digest-pinned Python/WASI runtime. Reproducibility is a
supply-chain requirement there: the generated component digest is part of the runtime
identity, not merely a build-cache optimization.
Summary
Running
componentize-py componentizetwice with the same command and byte-identicalinputs can produce valid but byte-different WebAssembly components. This prevents use in
content-addressed build and artifact-verification pipelines.
I would like maintainer guidance on the shape of an opt-in reproducible-build mode before
opening the larger implementation PR. A small independent PR that makes WASI stub-adapter
emission deterministic is ready separately.
Reproduction
Using current
mainataa3d6d1, Rust 1.95.0, WASI SDK 34, and the repository sandboxexample:
Expected: both files are byte-identical.
Actual: the files differ while remaining valid components.
Investigation
An experimental
--reproducibleimplementation fixed or controlled all of the following:PYTHONHASHSEED;PYTHONMALLOC=malloc_debug;Even with all of those controls, two current-main outputs still differed: one run produced
19,323,845 bytes, the other 19,325,725 bytes, with the first difference at component offset
9.
I then repeated the experiment with ambient user/system
site-packagesexcluded and thesame input, generated bindings, bundled helpers, and standard library exposed through stable
host directories. The outputs still differed (
292a9320...versusf35b0e6d...). Both hadthe same 208-page memory and 1,710 data segments, but reconstructing linear memory from those
segments found roughly 4.1 million differing bytes across 141 pages.
The difference is localized more narrowly than an encoder or linker problem:
byte-identical;
Additional two-run probes showed identical values for Python string hashes,
random, wall andmonotonic clocks, process/thread identity, visible directory ordering, generated
Symbols, andthe component immediately before pre-initialization. Full linear-memory fingerprints taken
inside the guest were identical after app import and after every
do_initphase (exports, typetables, constructors, environment, runtime hooks, argv, and full-generation GC).
However, immediately after
call_initreturned through the Component Model boundary, beforecomponent-init-transformmeasured globals or memory, the linear-memory fingerprints differed.Disabling either or both WASI adapter/libc reset calls did not change that result. Ordering the
internal
component-init-transformmaps, reusing stable-inode directories, collecting Pythonfree lists after return through a second no-argument guest call, and retaining the Rust-level
app_name/Symbolsvalues also did not fix it.This localizes the remaining nondeterminism to the generated canonical-ABI post-return path for
the large nested
init(app-name, symbols, stub-wasi) -> result<_, string>call (or allocatorstate changed by that path), rather than Python initialization, component encoding, or snapshot
measurement. The current-main update moved from wit-bindgen 0.53.1 to 0.61.0 and Wasmtime 46.0.1
to 48.0.0; the exact responsible layer still needs a minimal reduction.
The experiment deliberately did not use post-generation byte rewriting or a volatile-byte
allowlist.
Proposed direction
bytes.
change by default.
and bytecode behavior in that mode.
virtualized tree. The implementation should not modify user source-file metadata.
memory before post-return cleanup, making that cleanup allocator-deterministic, or changing
the private init protocol so the large nested argument graph does not leave volatile allocator
state in the captured memory. Any fix must preserve matching allocator globals and memory.
Would the maintainers prefer this as one opt-in feature PR, or as smaller PRs after the
stub-ordering fix?
Additional context
This was found while building a digest-pinned Python/WASI runtime. Reproducibility is a
supply-chain requirement there: the generated component digest is part of the runtime
identity, not merely a build-cache optimization.