From 9e4d2e75e2e883a23275334a28592ca1f29a383b Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 23 Jul 2026 18:18:32 +0200 Subject: [PATCH] feat(361): support same-memory string transcoding (lift the #360 hard-fail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #360 hard-failed a sync boundary that needs transcoding but does not cross memory (`--memory shared` fusion of a UTF-8 caller + UTF-16 callee) — the loud-not-silent placeholder for the gap the call-lowering seam surfaced. This lands the actual support. Root: the transcode adapter (`generate_transcoding_adapter`) is already memory-index-parameterised — source reads use `caller_memory`, destination writes use `callee_memory`, and it transcodes byte-by-byte (no cross-memory `memory.copy`). So when the two are the SAME memory (both 0 under `--memory shared`) it transcodes correctly within that one memory. Only the class dispatch was gating it: it reached `Transcode` only when memory was crossed. Fix = route `needs_transcoding` → `Transcode` regardless of `crosses_memory`, and drop the hard-fail. - call_lowering.rs: `resolve_call_lowering_plan` routes any transcoding boundary to `Transcode`; the `same_memory_transcoding_hard_fails` unit test becomes `..._routes_to_transcode`. - adapter_safety.rs: `test_361_same_memory_utf8_to_utf16_transcoding` — the SR-17 UTF-8→UTF-16 'Hello' fusion under `SharedMemory`, executed on wasmtime, returns 500 (correct transcode), not a verbatim mis-copy. (Confirmed it hard-failed before this change.) - verification-matrix: SR-17 gains the same-memory execution oracle. Full meld-core suite green (0 failures); fmt + clippy clean. Tier-5 (adapter/) → Mythos delta-pass to follow. Closes #361. Refs #360, #272, SR-17. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/verification-matrix.md | 2 +- meld-core/src/adapter/call_lowering.rs | 66 ++++++++++---------------- meld-core/tests/adapter_safety.rs | 59 +++++++++++++++++++++++ 3 files changed, 84 insertions(+), 43 deletions(-) diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md index 96e4005..c3d9796 100644 --- a/docs/verification-matrix.md +++ b/docs/verification-matrix.md @@ -24,7 +24,7 @@ | SR-14 | Correct memory index usage in adapters | partial | — | `proofs/adapter/` | | SR-15 | Correct list copy length | partial | `adapter_safety::test_sr15_list_copy_length` | — | | SR-16 | Recursive inner pointer fixup | partial | `adapter_safety::test_sr16_inner_pointer_fixup_list_string` | — | -| SR-17 | Correct string transcoding | verified | `adapter_safety::test_sr17_utf8_to_utf16_string_transcoding`
`adapter_safety::test_sr17_utf8_to_utf16_supplementary_plane_transcoding`
`adapter_safety::test_sr17_utf16_to_utf8_supplementary_plane_transcoding`
`adapter_safety::test_sr17_utf16_to_utf8_lone_high_surrogate_replacement`
`adapter_safety::test_sr17_utf16_to_utf8_midstring_lone_surrogate_replacement`
`adapter_safety::test_sr17_utf16_to_utf8_malformed_surrogate_matrix`
`adapter_safety::test_sr17_utf8_to_utf16_malformed_matrix`
`adapter_safety::test_sr17_latin1_to_utf16_transcoding`
`adapter_safety::test_253_read_latin1_tagclear_to_utf16`
`adapter_safety::test_253_read_latin1_tagclear_to_utf8`
`adapter_safety::test_253_read_latin1_tagset_to_utf16`
`adapter_safety::test_253_read_latin1_tagset_to_utf8_supplementary`
`adapter_safety::test_253_write_utf8_to_latin1_fits`
`adapter_safety::test_253_write_utf8_to_latin1_needs_utf16`
`adapter_safety::test_253_write_utf16_to_latin1_fits`
`adapter_safety::test_253_write_utf16_to_latin1_needs_utf16_supplementary`
`adapter_safety::ls_p_21_latin1_utf16_tag_honored_roundtrip`
`resolver::tests::test_sr17_all_encoding_pairs_transcoding_matrix` | — | +| SR-17 | Correct string transcoding | verified | `adapter_safety::test_sr17_utf8_to_utf16_string_transcoding`
`adapter_safety::test_sr17_utf8_to_utf16_supplementary_plane_transcoding`
`adapter_safety::test_sr17_utf16_to_utf8_supplementary_plane_transcoding`
`adapter_safety::test_sr17_utf16_to_utf8_lone_high_surrogate_replacement`
`adapter_safety::test_sr17_utf16_to_utf8_midstring_lone_surrogate_replacement`
`adapter_safety::test_sr17_utf16_to_utf8_malformed_surrogate_matrix`
`adapter_safety::test_sr17_utf8_to_utf16_malformed_matrix`
`adapter_safety::test_sr17_latin1_to_utf16_transcoding`
`adapter_safety::test_253_read_latin1_tagclear_to_utf16`
`adapter_safety::test_253_read_latin1_tagclear_to_utf8`
`adapter_safety::test_253_read_latin1_tagset_to_utf16`
`adapter_safety::test_253_read_latin1_tagset_to_utf8_supplementary`
`adapter_safety::test_253_write_utf8_to_latin1_fits`
`adapter_safety::test_253_write_utf8_to_latin1_needs_utf16`
`adapter_safety::test_253_write_utf16_to_latin1_fits`
`adapter_safety::test_253_write_utf16_to_latin1_needs_utf16_supplementary`
`adapter_safety::ls_p_21_latin1_utf16_tag_honored_roundtrip`
`resolver::tests::test_sr17_all_encoding_pairs_transcoding_matrix`
`adapter_safety::test_361_same_memory_utf8_to_utf16_transcoding` (same-memory, #361) | — | | SR-18 | Adapter instruction ordering | not-verified | — | — | | SR-19 | Deterministic output | partial | `tests::test_deterministic_output`
`resolver::tests::test_resolver_preserves_order_stability` | — | | SR-20 | Fail-fast on unresolvable state | partial | `tests::test_fuser_empty_components_error`
`tests::test_fuser_rejects_core_module_input`
`tests::test_fuser_address_rebasing_requires_shared_memory`
`tests::test_fuser_rejects_invalid_wasm` | — | diff --git a/meld-core/src/adapter/call_lowering.rs b/meld-core/src/adapter/call_lowering.rs index db3efcb..e4615fd 100644 --- a/meld-core/src/adapter/call_lowering.rs +++ b/meld-core/src/adapter/call_lowering.rs @@ -46,7 +46,7 @@ //! loud on the identical case (#272), so this closes the sync gap consistently. use super::AdapterClass; -use crate::{Error, Result}; +use crate::Result; /// The facts about one call boundary that determine how it is lowered. All are /// cheap booleans read from the resolved [`AdapterSite`](crate::resolver::AdapterSite) @@ -83,41 +83,25 @@ pub struct CallLoweringPlan { /// Resolve how a single cross-component call boundary is lowered. /// -/// Class selection (unchanged from the pre-extraction `generate_adapter`): -/// - cross-memory + encoding mismatch → `Transcode` +/// Class selection: +/// - encoding mismatch (needs transcoding) → `Transcode`, whether or not the +/// boundary crosses memory (#361 — the transcode adapter is +/// memory-index-parameterised, so it transcodes correctly within one shared +/// memory as well as across two) /// - cross-memory, same encoding → `MemoryCopy` -/// - same memory → `Direct` +/// - same memory, same encoding → `Direct` /// /// and `inline_eligible` iff inlining is on, the class is `Direct`, and the call /// carries no resource conversions and no post-return. -/// -/// # Errors -/// -/// Hard-fails ([`Error::AdapterGeneration`](crate::Error::AdapterGeneration)) -/// when the boundary needs transcoding but does **not** cross memory -/// (`needs_transcoding && !crosses_memory`). A same-memory boundary is lowered -/// as a `Direct` shim that does not transcode, so emitting one for a -/// mixed-encoding call would deliver string bytes verbatim in the wrong encoding -/// — a silent miscompile. This is the sync twin of `guard_async_cross_encoding_strings` -/// (the async path already fails loud here, #272). Supporting same-memory -/// transcoding is tracked in #361; until then we refuse rather than corrupt. pub fn resolve_call_lowering_plan(facts: BoundaryFacts) -> Result { - // #361: a boundary that needs transcoding but does not cross memory has no - // lowering that transcodes — `Direct` (the same-memory class) is a thin - // shim. Fail loud instead of silently mis-transcoding (mirrors the async - // guard, #272). - if facts.needs_transcoding && !facts.crosses_memory { - return Err(Error::AdapterGeneration( - "same-memory string transcoding is not supported: this call requires \ - transcoding (caller and callee use different string encodings) but \ - does not cross a memory boundary, so it would be lowered as a Direct \ - shim that copies the bytes verbatim — silently mis-transcoding. The \ - async path already fails loud here (#272); see #361 to support it" - .to_string(), - )); - } - - let class = if facts.crosses_memory && facts.needs_transcoding { + // #361: transcoding is required whenever the encodings differ, regardless of + // whether memory is crossed. Route to `Transcode`; the adapter reads from + // `caller_memory` and writes the transcoded bytes to `callee_memory` (both + // the one shared memory under `--memory shared`), so a same-memory boundary + // transcodes correctly rather than falling through to a verbatim `Direct` + // copy. (This lifts the #360 hard-fail, which was the loud-not-silent + // placeholder until this support landed.) + let class = if facts.needs_transcoding { AdapterClass::Transcode } else if facts.crosses_memory { AdapterClass::MemoryCopy @@ -190,20 +174,18 @@ mod tests { } #[test] - fn same_memory_transcoding_hard_fails() { - // #361: a boundary that needs transcoding but does not cross memory has - // no lowering that transcodes (Direct is a thin shim). Emitting one - // would silently mis-transcode, so the seam refuses — the sync twin of - // the async cross-encoding guard (#272). - let err = resolve_call_lowering_plan(BoundaryFacts { + fn same_memory_transcoding_routes_to_transcode() { + // #361: a boundary that needs transcoding but does NOT cross memory now + // routes to `Transcode` (the transcode adapter is memory-index- + // parameterised and transcodes within one shared memory), rather than + // hard-failing or falling through to a verbatim `Direct` copy. + let plan = resolve_call_lowering_plan(BoundaryFacts { needs_transcoding: true, ..direct() }) - .unwrap_err(); - assert!( - matches!(err, Error::AdapterGeneration(_)), - "expected AdapterGeneration hard-fail, got {err:?}" - ); + .unwrap(); + assert_eq!(plan.class, AdapterClass::Transcode); + assert!(!plan.inline_eligible, "Transcode is never inlinable"); } #[test] diff --git a/meld-core/tests/adapter_safety.rs b/meld-core/tests/adapter_safety.rs index 7c328b9..5252407 100644 --- a/meld-core/tests/adapter_safety.rs +++ b/meld-core/tests/adapter_safety.rs @@ -2095,6 +2095,65 @@ fn test_sr17_utf8_to_utf16_string_transcoding() { ); } +/// #361: the SAME transcoding, but under `SharedMemory` — the caller and callee +/// share one linear memory, so `crosses_memory` is false. #360 hard-failed this +/// (the class dispatch only reached `Transcode` when memory was crossed); this +/// proves same-memory transcoding now works: the transcode adapter is +/// memory-index-parameterised (src reads `caller_memory`, dst writes +/// `callee_memory` — both 0 here), so it transcodes UTF-8 → UTF-16 within the +/// one shared memory. `run()` must still return 500. +#[test] +fn test_361_same_memory_utf8_to_utf16_transcoding() { + let callee = build_callee_utf16_string_component(); + let caller = build_caller_string_component(); + + let config = FuserConfig { + memory_strategy: MemoryStrategy::SharedMemory, + attestation: false, + reproducible: false, + component_provenance: false, + address_rebasing: false, + preserve_names: false, + custom_sections: meld_core::CustomSectionHandling::Drop, + dwarf_handling: meld_core::DwarfHandling::Strip, + output_format: meld_core::OutputFormat::CoreModule, + opaque_resources: Vec::new(), + }; + + let mut fuser = Fuser::new(config); + fuser + .add_component_named(&callee, Some("callee-utf16")) + .expect("callee should parse"); + fuser + .add_component_named(&caller, Some("caller-utf8")) + .expect("caller should parse"); + + let (fused, _stats) = fuser + .fuse_with_stats() + .expect("#361: same-memory cross-encoding fusion should now succeed"); + + let mut validator = wasmparser::Validator::new(); + validator + .validate_all(&fused) + .expect("#361: fused output should validate"); + + let mut engine_config = Config::new(); + engine_config.wasm_multi_memory(true); + let engine = Engine::new(&engine_config).unwrap(); + let module = RuntimeModule::new(&engine, &fused).unwrap(); + let mut store = Store::new(&engine, ()); + let instance = Instance::new(&mut store, &module, &[]).unwrap(); + let run = instance + .get_typed_func::<(), i32>(&mut store, "run") + .expect("#361: fused module should export 'run'"); + let result = run.call(&mut store, ()).unwrap(); + assert_eq!( + result, 500, + "#361: same-memory UTF-8→UTF-16 'Hello' must transcode to 500, not \ + silently mis-copy" + ); +} + /// SR-17 (surrogate-pair clause): a supplementary-plane code point /// (U+10000 and above) transcoded UTF-8 → UTF-16 must be encoded as a /// surrogate PAIR — two code units — not a single truncated unit. The