From e811ef83e174b41cd17b8fe1216302dfa8ed9eb0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 10:30:38 -0800 Subject: [PATCH 01/15] chore(quest): claim quest/m0/nightly-green.md Quest-Claim: 74252aca17cdde91 From 46f567c80ebc8682f63093c5d49fc2442b0c09f6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 10:48:29 -0800 Subject: [PATCH 02/15] fix: write a JS Math result's NaN as one NaN, free lent bytes in their own frame on ART, and report the nightly's failures in an issue Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly.yml | 34 ++++++++++++++++++ ffi/tests/api.kt | 23 ++++++++----- ts/fixtures/bindgen/expected/harness.ts | 46 ++++++++++++++++++------- ts/fixtures/bindgen/src/harness.rs | 26 +++++++++----- 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0c2b2459e..3884f21cd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -269,3 +269,37 @@ jobs: with: name: census path: ts/target/census + + # A scheduled run's failure opens one issue, or comments on the one still open, and a + # pass closes it, so a suite only this workflow runs cannot fail unseen. Only the + # schedule, which runs main, gets the write token: a dispatch can run any branch. + report: + name: Report + needs: [gate, ts, bench, ffi, ffi-cpp, ffi-android, ffi-bench, census] + if: ${{ !cancelled() && github.event_name == 'schedule' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + issues: write + + steps: + - name: Issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + FAILED: ${{ contains(needs.*.result, 'failure') }} + RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SHA: ${{ github.sha }} + run: | + title="The nightly fails" + issue=$(gh issue list --state open --search "\"$title\" in:title" --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n 1) + if [ "$FAILED" = true ]; then + body="[This run]($RUN) failed at $SHA." + if [ -n "$issue" ]; then + gh issue comment "$issue" --body "$body" + else + gh issue create --title "$title" --body "$body" + fi + elif [ -n "$issue" ]; then + gh issue close "$issue" --comment "[This run]($RUN) passed at $SHA." + fi diff --git a/ffi/tests/api.kt b/ffi/tests/api.kt index 9ba4043cd..6f265e3d8 100644 --- a/ffi/tests/api.kt +++ b/ffi/tests/api.kt @@ -596,14 +596,7 @@ fun owned() { val sliced = nextPacket(Packet(1u, packet.payload.slice(1, n - 1))).payload check(sliced.toByteArray().contentEquals(payload.copyOfRange(1, n - 1)), "a slice of a record's $n bytes crosses as its range") } - var freed = dropped() - for (n in 1023..1025) { - val bytes = tokenBytes(n.toUInt(), n.toUInt()) - if (n <= 1024) freed++ - check(dropped() == freed, "$n bytes are freed at the call only if copied") - reads(bytes, n.toByte(), "$n bytes") - } - collected(freed + 1u, "the GC frees lent bytes nothing reaches") + collected(lentAround() + 1u, "the GC frees lent bytes nothing reaches") reads(Bytes(ByteArray(1025) { 9 }), 9, "Kotlin's bytes") // A call after close throws, and close frees at once, once. @@ -743,6 +736,20 @@ fun budgeted() { collected(before + 66u, "the GC frees bytes lent once the budget is free") } +// Rust's bytes either side of the copy limit, returning the drops once the call has freed +// those it copied. In its own frame, as ART's interpreter keeps the last ones reachable +// from the caller's until it returns. +fun lentAround(): UInt { + var freed = dropped() + for (n in 1023..1025) { + val bytes = tokenBytes(n.toUInt(), n.toUInt()) + if (n <= 1024) freed++ + check(dropped() == freed, "$n bytes are freed at the call only if copied") + reads(bytes, n.toByte(), "$n bytes") + } + return freed +} + // In its own frame, so nothing on the caller's keeps them reachable. fun lentToBudget(before: UInt) { val mib = 1 shl 20 diff --git a/ts/fixtures/bindgen/expected/harness.ts b/ts/fixtures/bindgen/expected/harness.ts index ba5afcb80..de159ec96 100644 --- a/ts/fixtures/bindgen/expected/harness.ts +++ b/ts/fixtures/bindgen/expected/harness.ts @@ -67,31 +67,53 @@ export function show(out: rs.String, what: string, v: rs.JsValue): void { rs.resultUnwrap(rs.fmtWriteString(out, [args3[0], ": ", args3[1], "\n"]), rs.fmtErrorDebug); } +/** + * A `Math` result's bits, any NaN as `f64::NAN`'s: ECMAScript leaves which NaN a + * function returns to the engine, and V8's varies by CPU and call path, both sides' + * calls being the same. + */ +export function opBits(x: rs.f64): rs.u64 { + let _2!: rs.f64; + if (!Number.isNaN(x)) { + _2 = x; + } else { + _2 = NaN; + } + return rs.f64ToBits(_2); +} + export function math(input: Input.Input, out: rs.String): void { + let _48!: rs.f32; const _5: [rs.f64, rs.f64] = [Input.f64(input), Input.f64(input)]; const x: rs.f64 = _5[0]; const y: rs.f64 = _5[1]; - const part = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").floor(x))); + const part = opBits(rs.jsToF64(rs.jsGlobal("Math").floor(x))); rs.resultUnwrap(rs.fmtWriteString(out, ["floor ", (f: rs.Formatter) => rs.u64Debug(part, f), "\n"]), rs.fmtErrorDebug); - const part2 = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").trunc(x))); + const part2 = opBits(rs.jsToF64(rs.jsGlobal("Math").trunc(x))); rs.resultUnwrap(rs.fmtWriteString(out, ["trunc ", (f: rs.Formatter) => rs.u64Debug(part2, f), "\n"]), rs.fmtErrorDebug); - const part3 = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").sign(x))); + const part3 = opBits(rs.jsToF64(rs.jsGlobal("Math").sign(x))); rs.resultUnwrap(rs.fmtWriteString(out, ["sign ", (f: rs.Formatter) => rs.u64Debug(part3, f), "\n"]), rs.fmtErrorDebug); - const part4 = rs.f32ToBits(rs.jsToF32(rs.jsGlobal("Math").fround(x))); + const fround: rs.f32 = rs.jsToF32(rs.jsGlobal("Math").fround(x)); + if (!Number.isNaN(fround)) { + _48 = fround; + } else { + _48 = NaN; + } + const part4 = rs.f32ToBits(_48); rs.resultUnwrap(rs.fmtWriteString(out, ["fround ", (f: rs.Formatter) => rs.u32Debug(part4, f), "\n"]), rs.fmtErrorDebug); - const part5 = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").hypot(x, y))); + const part5 = opBits(rs.jsToF64(rs.jsGlobal("Math").hypot(x, y))); rs.resultUnwrap(rs.fmtWriteString(out, ["hypot ", (f: rs.Formatter) => rs.u64Debug(part5, f), "\n"]), rs.fmtErrorDebug); - const part6 = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").atan2(x, y))); + const part6 = opBits(rs.jsToF64(rs.jsGlobal("Math").atan2(x, y))); rs.resultUnwrap(rs.fmtWriteString(out, ["atan2 ", (f: rs.Formatter) => rs.u64Debug(part6, f), "\n"]), rs.fmtErrorDebug); - const part7 = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").pow(x, y))); + const part7 = opBits(rs.jsToF64(rs.jsGlobal("Math").pow(x, y))); rs.resultUnwrap(rs.fmtWriteString(out, ["pow ", (f: rs.Formatter) => rs.u64Debug(part7, f), "\n"]), rs.fmtErrorDebug); - const part8 = rs.f64ToBits(rs.jsToF64(rs.jsGlobal("Math").max(x, y))); + const part8 = opBits(rs.jsToF64(rs.jsGlobal("Math").max(x, y))); rs.resultUnwrap(rs.fmtWriteString(out, ["max ", (f: rs.Formatter) => rs.u64Debug(part8, f), "\n"]), rs.fmtErrorDebug); - const _102: rs.i32 = Input.u32(input) | 0; - const part9 = rs.jsToU32(rs.jsGlobal("Math").clz32(_102)); + const _104: rs.i32 = Input.u32(input) | 0; + const part9 = rs.jsToU32(rs.jsGlobal("Math").clz32(_104)); rs.resultUnwrap(rs.fmtWriteString(out, ["clz32 ", (f: rs.Formatter) => rs.u32Display(part9, f), "\n"]), rs.fmtErrorDebug); - const _118: rs.Slice = rs.sliceRange(rs.sliceFrom([y, Input.f64(input), Input.f64(input)]), 0, rs.u8Rem(Input.u8(input), 4) as rs.usize); - const part11 = rs.f64ToBits(rs.jsToF64(rs.jsSpreadMethod(rs.jsGlobal("Math"), "max", [x], rs.jsTyped(rs.global.Float64Array, _118, false)))); + const _120: rs.Slice = rs.sliceRange(rs.sliceFrom([y, Input.f64(input), Input.f64(input)]), 0, rs.u8Rem(Input.u8(input), 4) as rs.usize); + const part11 = opBits(rs.jsToF64(rs.jsSpreadMethod(rs.jsGlobal("Math"), "max", [x], rs.jsTyped(rs.global.Float64Array, _120, false)))); rs.resultUnwrap(rs.fmtWriteString(out, ["max_of ", (f: rs.Formatter) => rs.u64Debug(part11, f), "\n"]), rs.fmtErrorDebug); } diff --git a/ts/fixtures/bindgen/src/harness.rs b/ts/fixtures/bindgen/src/harness.rs index 553f71910..ae7c13c94 100644 --- a/ts/fixtures/bindgen/src/harness.rs +++ b/ts/fixtures/bindgen/src/harness.rs @@ -187,22 +187,30 @@ fn show(out: &mut String, what: &str, v: &JsValue) { writeln!(out, "{what}: {text:?}").unwrap(); } +/// A `Math` result's bits, any NaN as `f64::NAN`'s: ECMAScript leaves which NaN a +/// function returns to the engine, and V8's varies by CPU and call path, both sides' +/// calls being the same. +fn op_bits(x: f64) -> u64 { + if x.is_nan() { f64::NAN } else { x }.to_bits() +} + fn math(input: &mut Input, out: &mut String) { let (x, y) = (input.f64(), input.f64()); - writeln!(out, "floor {:?}", Math::floor(x).to_bits()).unwrap(); - writeln!(out, "trunc {:?}", Math::trunc(x).to_bits()).unwrap(); - writeln!(out, "sign {:?}", Math::sign(x).to_bits()).unwrap(); - writeln!(out, "fround {:?}", Math::fround(x).to_bits()).unwrap(); - writeln!(out, "hypot {:?}", Math::hypot(x, y).to_bits()).unwrap(); - writeln!(out, "atan2 {:?}", Math::atan2(x, y).to_bits()).unwrap(); - writeln!(out, "pow {:?}", Math::pow(x, y).to_bits()).unwrap(); - writeln!(out, "max {:?}", Math::max(x, y).to_bits()).unwrap(); + writeln!(out, "floor {:?}", op_bits(Math::floor(x))).unwrap(); + writeln!(out, "trunc {:?}", op_bits(Math::trunc(x))).unwrap(); + writeln!(out, "sign {:?}", op_bits(Math::sign(x))).unwrap(); + let fround = Math::fround(x); + writeln!(out, "fround {:?}", if fround.is_nan() { f32::NAN } else { fround }.to_bits()).unwrap(); + writeln!(out, "hypot {:?}", op_bits(Math::hypot(x, y))).unwrap(); + writeln!(out, "atan2 {:?}", op_bits(Math::atan2(x, y))).unwrap(); + writeln!(out, "pow {:?}", op_bits(Math::pow(x, y))).unwrap(); + writeln!(out, "max {:?}", op_bits(Math::max(x, y))).unwrap(); writeln!(out, "clz32 {}", Math::clz32(input.u32() as i32)).unwrap(); let rest = [y, input.f64(), input.f64()]; writeln!( out, "max_of {:?}", - imports::max_of(x, &rest[..usize::from(input.u8() % 4)]).to_bits() + op_bits(imports::max_of(x, &rest[..usize::from(input.u8() % 4)])) ) .unwrap(); } From 5e2612384f6db88acd0c9db3b0d6550c7b569a04 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 11:10:26 -0800 Subject: [PATCH 03/15] fix(ts): format the bindgen harness's fround line Co-Authored-By: Claude Opus 5.5 --- ts/fixtures/bindgen/expected/harness.ts | 16 ++++++++-------- ts/fixtures/bindgen/src/harness.rs | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/ts/fixtures/bindgen/expected/harness.ts b/ts/fixtures/bindgen/expected/harness.ts index de159ec96..b9939771c 100644 --- a/ts/fixtures/bindgen/expected/harness.ts +++ b/ts/fixtures/bindgen/expected/harness.ts @@ -83,7 +83,7 @@ export function opBits(x: rs.f64): rs.u64 { } export function math(input: Input.Input, out: rs.String): void { - let _48!: rs.f32; + let fround2!: rs.f32; const _5: [rs.f64, rs.f64] = [Input.f64(input), Input.f64(input)]; const x: rs.f64 = _5[0]; const y: rs.f64 = _5[1]; @@ -95,11 +95,11 @@ export function math(input: Input.Input, out: rs.String): void { rs.resultUnwrap(rs.fmtWriteString(out, ["sign ", (f: rs.Formatter) => rs.u64Debug(part3, f), "\n"]), rs.fmtErrorDebug); const fround: rs.f32 = rs.jsToF32(rs.jsGlobal("Math").fround(x)); if (!Number.isNaN(fround)) { - _48 = fround; + fround2 = fround; } else { - _48 = NaN; + fround2 = NaN; } - const part4 = rs.f32ToBits(_48); + const part4 = rs.f32ToBits(fround2); rs.resultUnwrap(rs.fmtWriteString(out, ["fround ", (f: rs.Formatter) => rs.u32Debug(part4, f), "\n"]), rs.fmtErrorDebug); const part5 = opBits(rs.jsToF64(rs.jsGlobal("Math").hypot(x, y))); rs.resultUnwrap(rs.fmtWriteString(out, ["hypot ", (f: rs.Formatter) => rs.u64Debug(part5, f), "\n"]), rs.fmtErrorDebug); @@ -109,11 +109,11 @@ export function math(input: Input.Input, out: rs.String): void { rs.resultUnwrap(rs.fmtWriteString(out, ["pow ", (f: rs.Formatter) => rs.u64Debug(part7, f), "\n"]), rs.fmtErrorDebug); const part8 = opBits(rs.jsToF64(rs.jsGlobal("Math").max(x, y))); rs.resultUnwrap(rs.fmtWriteString(out, ["max ", (f: rs.Formatter) => rs.u64Debug(part8, f), "\n"]), rs.fmtErrorDebug); - const _104: rs.i32 = Input.u32(input) | 0; - const part9 = rs.jsToU32(rs.jsGlobal("Math").clz32(_104)); + const _105: rs.i32 = Input.u32(input) | 0; + const part9 = rs.jsToU32(rs.jsGlobal("Math").clz32(_105)); rs.resultUnwrap(rs.fmtWriteString(out, ["clz32 ", (f: rs.Formatter) => rs.u32Display(part9, f), "\n"]), rs.fmtErrorDebug); - const _120: rs.Slice = rs.sliceRange(rs.sliceFrom([y, Input.f64(input), Input.f64(input)]), 0, rs.u8Rem(Input.u8(input), 4) as rs.usize); - const part11 = opBits(rs.jsToF64(rs.jsSpreadMethod(rs.jsGlobal("Math"), "max", [x], rs.jsTyped(rs.global.Float64Array, _120, false)))); + const _121: rs.Slice = rs.sliceRange(rs.sliceFrom([y, Input.f64(input), Input.f64(input)]), 0, rs.u8Rem(Input.u8(input), 4) as rs.usize); + const part11 = opBits(rs.jsToF64(rs.jsSpreadMethod(rs.jsGlobal("Math"), "max", [x], rs.jsTyped(rs.global.Float64Array, _121, false)))); rs.resultUnwrap(rs.fmtWriteString(out, ["max_of ", (f: rs.Formatter) => rs.u64Debug(part11, f), "\n"]), rs.fmtErrorDebug); } diff --git a/ts/fixtures/bindgen/src/harness.rs b/ts/fixtures/bindgen/src/harness.rs index ae7c13c94..cc2f45ad8 100644 --- a/ts/fixtures/bindgen/src/harness.rs +++ b/ts/fixtures/bindgen/src/harness.rs @@ -200,7 +200,8 @@ fn math(input: &mut Input, out: &mut String) { writeln!(out, "trunc {:?}", op_bits(Math::trunc(x))).unwrap(); writeln!(out, "sign {:?}", op_bits(Math::sign(x))).unwrap(); let fround = Math::fround(x); - writeln!(out, "fround {:?}", if fround.is_nan() { f32::NAN } else { fround }.to_bits()).unwrap(); + let fround = if fround.is_nan() { f32::NAN } else { fround }; + writeln!(out, "fround {:?}", fround.to_bits()).unwrap(); writeln!(out, "hypot {:?}", op_bits(Math::hypot(x, y))).unwrap(); writeln!(out, "atan2 {:?}", op_bits(Math::atan2(x, y))).unwrap(); writeln!(out, "pow {:?}", op_bits(Math::pow(x, y))).unwrap(); From 8a91504114c0491a5a206ab9e596fd095ad6c5a4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 12:07:57 -0800 Subject: [PATCH 04/15] fix(ffi): name Kotlin's write-back reader apart from the parameters, and exclude flac's md5 array Co-Authored-By: Claude Opus 5.5 --- ffi/fixtures/api/src/lib.rs | 11 ++++++----- ffi/moq/ffi.toml | 1 + ffi/src/kotlin.rs | 20 ++++++++++++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/ffi/fixtures/api/src/lib.rs b/ffi/fixtures/api/src/lib.rs index 658281d98..8ce6cdc02 100644 --- a/ffi/fixtures/api/src/lib.rs +++ b/ffi/fixtures/api/src/lib.rs @@ -1787,13 +1787,14 @@ pub fn put_number(value: u32, buf: &mut impl bytes::BufMut) { buf.put_slice(format!("{value},").as_bytes()); } -/// Takes the first byte of `buf` as a decimal digit, as moq's decoders take from an +/// Takes the first byte of `r` as a decimal digit, as moq's decoders take from an /// `impl Buf`, which takes `Bytes`. It takes the byte even when it fails, and Rust writes -/// back what the call left on either path. -pub fn digit(buf: &mut impl bytes::Buf) -> Result { - match buf.has_remaining() { +/// back what the call left on either path. Named `r`, as moq's `varint::decode_quic`'s +/// is, the name a binding's reader of what Rust wrote back might take. +pub fn digit(r: &mut impl bytes::Buf) -> Result { + match r.has_remaining() { false => Err(Error::Underflow { by: 1 }), - true => match buf.get_u8() { + true => match r.get_u8() { digit @ b'0'..=b'9' => Ok(digit - b'0'), _ => Err(Error::Overflow), }, diff --git a/ffi/moq/ffi.toml b/ffi/moq/ffi.toml index 05acde3c7..451cd25cd 100644 --- a/ffi/moq/ffi.toml +++ b/ffi/moq/ffi.toml @@ -20,6 +20,7 @@ exclude = [ "clock::timeout_at", "coop::Budget::poll_yield", "datagram::Producer::poll_priority", + "field@mux::codec::flac::Config::md5", "field@tokio::Log::level", "frame::Producer::group", "goaway::Consumer::poll", diff --git a/ffi/src/kotlin.rs b/ffi/src/kotlin.rs index 81e48fb78..57b970350 100644 --- a/ffi/src/kotlin.rs +++ b/ffi/src/kotlin.rs @@ -307,6 +307,16 @@ impl<'a> Gen<'a> { format!("{base}{}", self.fresh) } + /// A fresh Kotlin name that is none of `params`, for a local in their scope. + fn fresh_beside(&mut self, base: &str, params: &[String]) -> String { + loop { + let name = self.fresh(base); + if !params.contains(&name) { + return name; + } + } + } + /// A C name without the prefix. fn bare<'n>(&self, c: &'n str) -> &'n str { &c[self.abi.prefix.len() + 1..] @@ -1536,7 +1546,7 @@ impl<'a> Gen<'a> { .collect(); let mut exprs: Vec = args.iter().flat_map(|a| a.exprs.clone()).collect(); // A future's call also takes the signal its waker parks, if its first poll pends. - let signal = export.future.is_some().then(|| self.fresh("s")); + let signal = export.future.is_some().then(|| self.fresh_beside("s", &names)); exprs.extend(signal.clone()); let rust_pre: Vec = args.iter().flat_map(|a| a.rust_pre.clone()).collect(); let rust_args: Vec = args.iter().flat_map(|a| a.rust_args.clone()).collect(); @@ -1655,16 +1665,18 @@ impl<'a> Gen<'a> { ), ); let mut lines = Vec::new(); + // The reader, named apart from the parameters it writes back to. + let r = self.fresh_beside("r", &names); // What Rust wrote back comes first, read whether or not the call failed. for (name, ty) in &written { - let read = self.read_value(ty, "r"); + let read = self.read_value(ty, &r); lines.push(format!("{name}.value = {}", to_param(ty, &read))); } - lines.extend(self.lift_result(&ok, err.as_ref(), exception.as_deref(), "r")); + lines.extend(self.lift_result(&ok, err.as_ref(), exception.as_deref(), &r)); value = match lines.is_empty() { true => call, false => format!( - "Native.Reader({call}).let {{ r ->\n{}}}", + "Native.Reader({call}).let {{ {r} ->\n{}}}", lines.iter().map(|l| format!("\t{l}\n")).collect::() ), }; From 7f6b3f227beb4912cd4153401d1df25acf47bc7d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 12:38:19 -0800 Subject: [PATCH 05/15] quest: finish the nightly passes Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 1 - quest/m0/nightly-green.md | 39 --------------------------------------- 2 files changed, 40 deletions(-) delete mode 100644 quest/m0/nightly-green.md diff --git a/quest/m0/README.md b/quest/m0/README.md index 492dd18a0..5b1f68409 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -8,4 +8,3 @@ each run: the checks, a red nightly, and code no consumer uses. ## Required - [Checks at moq's cost](/quest/m0/checks/README.md) - `just ci` selects by the diff, builds warm, and runs the differential optimized, with a nightly full `just gate` -- [The nightly passes](/quest/m0/nightly-green.md) - red since 2026-10-29: the wasm-bindgen fixture's NaN, Kotlin on Android API 35, and `just ffi moq` diff --git a/quest/m0/nightly-green.md b/quest/m0/nightly-green.md deleted file mode 100644 index a1cf78922..000000000 --- a/quest/m0/nightly-green.md +++ /dev/null @@ -1,39 +0,0 @@ -# [M] The nightly passes - -## Goal - -Every job of the scheduled `nightly.yml` run passes on `main`, each failure fixed at its -cause, and a regression in a suite only the nightly runs cannot go unseen for a week again. - -## Plan - -Found in the 2026-11-09 quest audit: the nightly has failed every scheduled run since -2026-10-29, with no quest owning it. Decided with the maintainer on 2026-11-09: ✅ m0, -after the UniFFI fork's deletion removed the `uniffi-*` jobs, so this fixes only what -remains. Rejected: m1. - -What failed on 2026-11-09 (run -[42655190735](https://github.com/moq-dev/rust2/actions/runs/42655190735), at `f7a0c279`): - -- `Gate` and `TypeScript package`: rust2-ts's `bindgen` fixture differs from native in 1 - of 5073 cases, a NaN's payload through the variadic import (`max_of` in - `ts/fixtures/bindgen/src/harness.rs`: native `18446472823932715226`, translated - `9221120237041090560`). "Encoder in Chromium" runs after that step, so the WebCodecs - encoder proof [#681](https://github.com/moq-dev/rust2/pull/681) added has never run in - CI. Reproduce first; the fix is in the translation or the import's glue, not in making - the harness blind to NaN payloads, unless JS cannot keep one through a call, which the - PR then shows. -- "Export to Kotlin on Android" at API 35: `the GC frees lent bytes nothing reaches`, for - at least three nights. -- `just ffi moq` likely fails on `main` since - [#668](https://github.com/moq-dev/rust2/pull/668): the exporter now refuses every - whole-crate `pub use` (`ffi/src/collect.rs`), and `ffi/moq/ffi.toml` excludes nine of - the eleven at the pin but not hang's and moq-tokio's `pub use moq_net;`. Not yet run in - CI, since no nightly ran after the merge. [The pin bump](/quest/m1/ffi-export/refusals/moq-pin.md) - removes them if it lands first. - -Open: what keeps it green. Recommended: the nightly's failure opens or updates one GitHub -issue the maintainer sees, and `just ffi moq`'s export step joins `just ci` when `ffi/` -or the pin changes, if it fits the gate's budget; measure it and report. - -Public API: none. From 7c0b6f90f91b0b1a21c98d93afa14f9811e8f529 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 13:46:12 -0800 Subject: [PATCH 06/15] fix(ffi): accept an exclude of a field or variant a cfg leaves out of this build rustc records only the items a cfg strips, so the exporter reads the type's declaration to tell a member a cfg removed from a misspelled one: on Windows, moq-tokio's listen::Config::unix. Co-Authored-By: Claude Opus 5.5 --- ffi/fixtures/dep/src/lib.rs | 3 ++ ffi/src/collect.rs | 23 +++++-------- ffi/src/driver.rs | 65 +++++++++++++++++++++++++++++++++---- ffi/src/main.rs | 2 ++ ffi/tests/fixtures.rs | 11 ++++--- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/ffi/fixtures/dep/src/lib.rs b/ffi/fixtures/dep/src/lib.rs index bb143e1e2..8f89b6bf6 100644 --- a/ffi/fixtures/dep/src/lib.rs +++ b/ffi/fixtures/dep/src/lib.rs @@ -5,6 +5,9 @@ pub struct Size { pub width: u32, pub height: u32, + /// What no build has, which the `configured_out` test excludes. + #[cfg(any())] + pub depth: u32, } /// An area: an object of this crate, which the api fixture's signatures take and return. diff --git a/ffi/src/collect.rs b/ffi/src/collect.rs index baa519c1e..a1f3f5d1b 100644 --- a/ffi/src/collect.rs +++ b/ffi/src/collect.rs @@ -1686,9 +1686,8 @@ impl<'tcx> Collector<'tcx> { || (api.records.iter()).any(|r| args(&r.args) || fields(&r.fields) || r.methods.iter().any(signature)) || (api.enums.iter()).any(|e| { args(&e.args) || e.variants.iter().any(|v| fields(&v.fields)) || e.methods.iter().any(signature) - }) - || (api.objects.iter().enumerate()) - .any(|(i, o)| i != gone && (args(&o.args) || o.methods.iter().any(signature))) + }) || (api.objects.iter().enumerate()) + .any(|(i, o)| i != gone && (args(&o.args) || o.methods.iter().any(signature))) || (api.callbacks.iter()).any(|c| c.methods.iter().any(signature)); if used { continue; @@ -2950,8 +2949,7 @@ impl<'tcx> Collector<'tcx> { .inputs() .iter() .filter(|&&input| input == param) - .count() - > 1 + .count() > 1 { return Some(Err(why("closure type taken twice", param.to_string()))); } @@ -3156,16 +3154,11 @@ impl<'tcx> Collector<'tcx> { let plain = matches!( ty.kind(), ty::Adt(..) - | ty::Bool - | ty::Char - | ty::Int(_) - | ty::Uint(_) - | ty::Float(_) - | ty::Str - | ty::Array(..) - | ty::Slice(_) - | ty::Ref(..) - | ty::Tuple(_) + | ty::Bool | ty::Char + | ty::Int(_) | ty::Uint(_) + | ty::Float(_) | ty::Str + | ty::Array(..) | ty::Slice(_) + | ty::Ref(..) | ty::Tuple(_) ); let (method, driven) = match (implements(iterator, ty), implements(into_iterator, ty)) { (true, _) => (next, item), diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 29f0f3206..28fc088e5 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; +use rustc_ast as ast; use rustc_data_structures::fx::FxHashSet; use rustc_driver::{Callbacks, Compilation}; use rustc_hir::def::{DefKind, Res}; @@ -11,7 +12,9 @@ use rustc_hir::def_id::{DefId, LOCAL_CRATE}; use rustc_interface::interface::Compiler; use rustc_middle::middle::resolve::Reexport; use rustc_middle::ty::TyCtxt; -use rustc_span::Span; +use rustc_parse::lexer::StripTokens; +use rustc_parse::parser::{AllowConstBlockItems, ForceCollect}; +use rustc_span::{FileName, Span, sym}; use crate::abi::Abi; use crate::config::{self, Config}; @@ -206,13 +209,60 @@ pub fn write(path: &Path, text: &str) -> Result<(), String> { std::fs::write(path, text).map_err(|err| format!("writing {}: {err}", path.display())) } -/// Whether `path`, from the facade's root, names an item a `cfg` leaves out of this build: -/// the config names it for the builds that have it. Its first segment no module has is -/// stripped from that module or from one the module glob-re-exports. +/// Whether the struct, enum, or union `adt` declares a field or variant `name` under a +/// `cfg` this build left it out by. rustc records only the items a `cfg` strips, not their +/// members, so this reads the declaration from its source. +fn stripped_member(tcx: TyCtxt<'_>, adt: DefId, name: &str) -> bool { + if tcx.adt_def(adt).all_fields().any(|f| f.name.as_str() == name) + || tcx.adt_def(adt).variants().iter().any(|v| v.name.as_str() == name) + { + return false; + } + // The item from its name on, which the parser reads up to its end. + let source_map = tcx.sess.source_map(); + let head = tcx.def_span(adt); + let source_file = source_map.lookup_source_file(head.lo()); + let Ok(source) = source_map.span_to_snippet(head.with_hi(source_file.end_position())) else { + return false; + }; + let file = FileName::anon_source_code(&source); + let mut parser = match rustc_parse::new_parser_from_source_str(&tcx.sess.psess, file, source, StripTokens::Nothing) + { + Ok(parser) => parser, + Err(errors) => { + errors.into_iter().for_each(|e| e.cancel()); + return false; + } + }; + let item = match parser.parse_item(ForceCollect::No, AllowConstBlockItems::DoesNotMatter) { + Ok(Some(item)) => item, + Ok(None) => return false, + Err(e) => { + e.cancel(); + return false; + } + }; + let configured = |attrs: &[ast::Attribute]| attrs.iter().any(|attr| attr.has_name(sym::cfg)); + match &item.kind { + ast::ItemKind::Struct(_, _, data) | ast::ItemKind::Union(_, _, data) => { + (data.fields().iter()).any(|f| f.ident.is_some_and(|ident| ident.as_str() == name) && configured(&f.attrs)) + } + ast::ItemKind::Enum(_, _, def) => { + (def.variants.iter()).any(|v| v.ident.as_str() == name && configured(&v.attrs)) + } + _ => false, + } +} + +/// Whether `path`, from the facade's root, names an item a `cfg` leaves out of this build, +/// or a field or variant of one: the config names it for the builds that have it. Its first +/// segment no module has is stripped from that module or from one the module +/// glob-re-exports. fn configured_out(tcx: TyCtxt<'_>, path: &str) -> bool { let path = path.strip_prefix("field@").unwrap_or(path); + let segments: Vec<&str> = path.split("::").collect(); let mut module = LOCAL_CRATE.as_def_id(); - for segment in path.split("::") { + for (i, segment) in segments.iter().enumerate() { let name = segment.split('<').next().unwrap_or(segment); let children = match module.as_local() { Some(local) => tcx.module_children_local(local), @@ -224,7 +274,10 @@ fn configured_out(tcx: TyCtxt<'_>, path: &str) -> bool { module = def_id; continue; } - // A member of an item, which no `cfg` this checks strips. + Res::Def(DefKind::Struct | DefKind::Enum | DefKind::Union, def_id) if i + 2 == segments.len() => { + return stripped_member(tcx, def_id, segments[i + 1]); + } + // A method, or a member past one, which no `cfg` this checks strips. _ => return false, } } diff --git a/ffi/src/main.rs b/ffi/src/main.rs index 85a685f85..e7e59c708 100644 --- a/ffi/src/main.rs +++ b/ffi/src/main.rs @@ -7,6 +7,7 @@ #![feature(rustc_private)] +extern crate rustc_ast; extern crate rustc_data_structures; extern crate rustc_driver; extern crate rustc_errors; @@ -14,6 +15,7 @@ extern crate rustc_hir; extern crate rustc_infer; extern crate rustc_interface; extern crate rustc_middle; +extern crate rustc_parse; extern crate rustc_session; extern crate rustc_span; extern crate rustc_trait_selection; diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 0d3512279..08c198e74 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -1439,8 +1439,8 @@ fn foreign_role_names() { /// An exclude may name what a `cfg` leaves out of this build, whether the facade's own /// module or another crate's that the facade re-exports through a glob, or a chain of them, -/// but not what no build has, nor what a module only some of whose items the facade -/// re-exports leaves out. +/// or a field or variant of the facade's or another crate's type, but not what no build +/// has, nor what a module only some of whose items the facade re-exports leaves out. #[test] fn configured_out() { let package = package("configured_out"); @@ -1458,11 +1458,13 @@ fn configured_out() { "src/lib.rs", "pub use ::dep::Size;\n\npub mod dep {\n\tpub use ::dep::*;\n}\n\n#[cfg(any())]\npub mod local {}\n\n\ mod inner {\n\tpub fn kept() -> u32 {\n\t\t1\n\t}\n\n\t#[cfg(any())]\n\tpub mod gone {}\n}\n\n\ - mod middle {\n\tpub use crate::inner::*;\n}\n\npub mod outer {\n\tpub use crate::middle::*;\n}\n", + mod middle {\n\tpub use crate::inner::*;\n}\n\npub mod outer {\n\tpub use crate::middle::*;\n}\n\n\ + pub struct Shape {\n\tpub n: u32,\n\t#[cfg(any())]\n\tpub gone: u32,\n}\n\n\ + pub enum Kind {\n\tKept,\n\t#[cfg(any())]\n\tGone,\n}\n", ), ( "ffi.toml", - "exclude = [\"Gone\", \"dep::gone::Gone\", \"gone::Gone\", \"local::Gone\", \"outer::gone::Gone\"]\n", + "exclude = [\"Gone\", \"Kind::Gone\", \"Shape::gone\", \"Shape::missing\", \"Size::depth\", \"dep::gone::Gone\", \"gone::Gone\", \"local::Gone\", \"outer::gone::Gone\"]\n", ), ], ); @@ -1480,6 +1482,7 @@ fn configured_out() { errors, [ "error: rust2-ffi: exclude `Gone` in the config names nothing exported", + "error: rust2-ffi: exclude `Shape::missing` in the config names nothing exported", "error: rust2-ffi: exclude `gone::Gone` in the config names nothing exported", ], "{stderr}" From 58bce9e7061829ee377d07a8d2d2d3e7aaaf6ea1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 13:56:57 -0800 Subject: [PATCH 07/15] fix(ffi): keep the Android script's lending functions from R8's inlining R8 inlined lentAround and lentToBudget into main, whose dex register still held the last lent Bytes when the test waited for the GC to free it, which ART's interpreter reaches on API 35. Co-Authored-By: Claude Opus 5.5 --- ffi/tests/api.kt | 7 ++++--- ffi/tests/fixtures.rs | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ffi/tests/api.kt b/ffi/tests/api.kt index e2e0c8418..86fd36ebb 100644 --- a/ffi/tests/api.kt +++ b/ffi/tests/api.kt @@ -786,8 +786,8 @@ fun budgeted() { } // Rust's bytes either side of the copy limit, returning the drops once the call has freed -// those it copied. In its own frame, as ART's interpreter keeps the last ones reachable -// from the caller's until it returns. +// those it copied. In its own frame, which the Android test keeps from R8's inlining, as +// ART's interpreter keeps the last ones reachable from the caller's until it returns. fun lentAround(): UInt { var freed = dropped() for (n in 1023..1025) { @@ -799,7 +799,8 @@ fun lentAround(): UInt { return freed } -// In its own frame, so nothing on the caller's keeps them reachable. +// In its own frame, which the Android test keeps from R8's inlining, so nothing on the +// caller's keeps them reachable. fun lentToBudget(before: UInt) { val mib = 1 shl 20 val held = List(64) { tokenBytes(1u, mib.toUInt()) } diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 08c198e74..9537b0df6 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -398,7 +398,9 @@ fn api_android() { // The app: the script's entry point, kept as an app keeps its own, and everything it // reaches, which R8 shrinks and renames, keeping only what the rules keep: the // generated ones, and those kotlinx-coroutines' jar holds, which an app's build reads - // from it. Kotlin's annotations are on an app's classpath, not in its dex. + // from it. Kotlin's annotations are on an app's classpath, not in its dex. The + // script's functions that hold lent bytes in their own frame are kept too, as R8 would + // inline them into their caller, whose frame ART's interpreter keeps reaching the bytes. let jar = dir.join("app.jar"); run(kotlinc(&out, &jar).arg("-include-runtime")); run(Command::new("jar") @@ -410,7 +412,7 @@ fn api_android() { std::fs::write( &app, format!( - "-keep class ApiKt {{ public static void main(java.lang.String[]); }}\n-dontwarn org.jetbrains.annotations.**\n-printusage {}\n", + "-keep class ApiKt {{ public static void main(java.lang.String[]); static *** lentAround(); static *** lentToBudget*(...); }}\n-dontwarn org.jetbrains.annotations.**\n-printusage {}\n", dir.join("usage.txt").display() ), ) From adfad41db1d172ecfe0282eccf46d39f8e13f8b9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 13:58:29 -0800 Subject: [PATCH 08/15] fix(ffi): restore collect.rs's formatting Co-Authored-By: Claude Opus 5.5 --- ffi/src/collect.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/ffi/src/collect.rs b/ffi/src/collect.rs index a1f3f5d1b..baa519c1e 100644 --- a/ffi/src/collect.rs +++ b/ffi/src/collect.rs @@ -1686,8 +1686,9 @@ impl<'tcx> Collector<'tcx> { || (api.records.iter()).any(|r| args(&r.args) || fields(&r.fields) || r.methods.iter().any(signature)) || (api.enums.iter()).any(|e| { args(&e.args) || e.variants.iter().any(|v| fields(&v.fields)) || e.methods.iter().any(signature) - }) || (api.objects.iter().enumerate()) - .any(|(i, o)| i != gone && (args(&o.args) || o.methods.iter().any(signature))) + }) + || (api.objects.iter().enumerate()) + .any(|(i, o)| i != gone && (args(&o.args) || o.methods.iter().any(signature))) || (api.callbacks.iter()).any(|c| c.methods.iter().any(signature)); if used { continue; @@ -2949,7 +2950,8 @@ impl<'tcx> Collector<'tcx> { .inputs() .iter() .filter(|&&input| input == param) - .count() > 1 + .count() + > 1 { return Some(Err(why("closure type taken twice", param.to_string()))); } @@ -3154,11 +3156,16 @@ impl<'tcx> Collector<'tcx> { let plain = matches!( ty.kind(), ty::Adt(..) - | ty::Bool | ty::Char - | ty::Int(_) | ty::Uint(_) - | ty::Float(_) | ty::Str - | ty::Array(..) | ty::Slice(_) - | ty::Ref(..) | ty::Tuple(_) + | ty::Bool + | ty::Char + | ty::Int(_) + | ty::Uint(_) + | ty::Float(_) + | ty::Str + | ty::Array(..) + | ty::Slice(_) + | ty::Ref(..) + | ty::Tuple(_) ); let (method, driven) = match (implements(iterator, ty), implements(into_iterator, ty)) { (true, _) => (next, item), From 8b77259440051c20a3740ae72e75c19347f4ca3c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 14:30:41 -0800 Subject: [PATCH 09/15] fix(ffi): read a cfg-stripped member's type up to its own end, so one in an inline module parses Co-Authored-By: Claude Opus 5.5 --- ffi/src/driver.rs | 22 ++++++++++++++++++++-- ffi/src/main.rs | 1 + ffi/tests/fixtures.rs | 10 ++++++---- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 28fc088e5..43d123835 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -12,6 +12,7 @@ use rustc_hir::def_id::{DefId, LOCAL_CRATE}; use rustc_interface::interface::Compiler; use rustc_middle::middle::resolve::Reexport; use rustc_middle::ty::TyCtxt; +use rustc_lexer::{FrontmatterAllowed, TokenKind}; use rustc_parse::lexer::StripTokens; use rustc_parse::parser::{AllowConstBlockItems, ForceCollect}; use rustc_span::{FileName, Span, sym}; @@ -218,13 +219,30 @@ fn stripped_member(tcx: TyCtxt<'_>, adt: DefId, name: &str) -> bool { { return false; } - // The item from its name on, which the parser reads up to its end. + // The item from its name on, up to the `;` or closing delimiter that ends it: past it the + // file may close a module the item is in, which the parser would refuse unmatched. let source_map = tcx.sess.source_map(); let head = tcx.def_span(adt); let source_file = source_map.lookup_source_file(head.lo()); - let Ok(source) = source_map.span_to_snippet(head.with_hi(source_file.end_position())) else { + let Ok(mut source) = source_map.span_to_snippet(head.with_hi(source_file.end_position())) else { return false; }; + let (mut depth, mut at) = (0usize, 0usize); + for token in rustc_lexer::tokenize(&source, FrontmatterAllowed::No) { + at += token.len as usize; + match token.kind { + TokenKind::OpenParen | TokenKind::OpenBrace | TokenKind::OpenBracket => depth += 1, + TokenKind::CloseParen | TokenKind::CloseBrace | TokenKind::CloseBracket => { + depth = depth.saturating_sub(1); + if depth == 0 && token.kind == TokenKind::CloseBrace { + break; + } + } + TokenKind::Semi if depth == 0 => break, + _ => {} + } + } + source.truncate(at); let file = FileName::anon_source_code(&source); let mut parser = match rustc_parse::new_parser_from_source_str(&tcx.sess.psess, file, source, StripTokens::Nothing) { diff --git a/ffi/src/main.rs b/ffi/src/main.rs index e7e59c708..085b65a53 100644 --- a/ffi/src/main.rs +++ b/ffi/src/main.rs @@ -14,6 +14,7 @@ extern crate rustc_errors; extern crate rustc_hir; extern crate rustc_infer; extern crate rustc_interface; +extern crate rustc_lexer; extern crate rustc_middle; extern crate rustc_parse; extern crate rustc_session; diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 9537b0df6..9162433c8 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -1441,8 +1441,9 @@ fn foreign_role_names() { /// An exclude may name what a `cfg` leaves out of this build, whether the facade's own /// module or another crate's that the facade re-exports through a glob, or a chain of them, -/// or a field or variant of the facade's or another crate's type, but not what no build -/// has, nor what a module only some of whose items the facade re-exports leaves out. +/// or a field or variant of the facade's or another crate's type, an inline module's +/// included, but not what no build has, nor what a module only some of whose items the +/// facade re-exports leaves out. #[test] fn configured_out() { let package = package("configured_out"); @@ -1462,11 +1463,12 @@ fn configured_out() { mod inner {\n\tpub fn kept() -> u32 {\n\t\t1\n\t}\n\n\t#[cfg(any())]\n\tpub mod gone {}\n}\n\n\ mod middle {\n\tpub use crate::inner::*;\n}\n\npub mod outer {\n\tpub use crate::middle::*;\n}\n\n\ pub struct Shape {\n\tpub n: u32,\n\t#[cfg(any())]\n\tpub gone: u32,\n}\n\n\ - pub enum Kind {\n\tKept,\n\t#[cfg(any())]\n\tGone,\n}\n", + pub enum Kind {\n\tKept,\n\t#[cfg(any())]\n\tGone,\n}\n\n\ + pub mod nested {\n\tpub struct Inner {\n\t\tpub n: u32,\n\t\t#[cfg(any())]\n\t\tpub gone: u32,\n\t}\n}\n", ), ( "ffi.toml", - "exclude = [\"Gone\", \"Kind::Gone\", \"Shape::gone\", \"Shape::missing\", \"Size::depth\", \"dep::gone::Gone\", \"gone::Gone\", \"local::Gone\", \"outer::gone::Gone\"]\n", + "exclude = [\"Gone\", \"Kind::Gone\", \"Shape::gone\", \"Shape::missing\", \"Size::depth\", \"nested::Inner::gone\", \"dep::gone::Gone\", \"gone::Gone\", \"local::Gone\", \"outer::gone::Gone\"]\n", ), ], ); From 28efd36e6192664f2fee619ea7d5d9906b799174 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 14:35:57 -0800 Subject: [PATCH 10/15] fix(ffi): take the catalog's lent Extra as a borrow in moq.cpp, as #694's C++ lends Co-Authored-By: Claude Opus 5.5 --- ffi/moq/moq.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ffi/moq/moq.cpp b/ffi/moq/moq.cpp index 75cc993d8..2f948eaf8 100644 --- a/ffi/moq/moq.cpp +++ b/ffi/moq/moq.cpp @@ -286,8 +286,8 @@ int main() { CHECK(catalog); CHECK(!catalog->is_claimed_BinaryConfig("state")); int read_catalog = 0; - CHECK(catalog->mutate([&](moq::hang_Catalog_Extra &lent) { - auto json = lent.to_json(); + CHECK(catalog->mutate([&](moq::borrow lent) { + auto json = lent->to_json(); CHECK(json); if (json && json.value().str().substr(0, 1) == "{") { read_catalog++; @@ -318,15 +318,15 @@ int main() { } auto app = moq::Json::parse(R"({"v": [1, true]})"); CHECK(app); - CHECK(catalog->mutate([&](moq::hang_Catalog_Extra &lent) { - moq::mux_catalog_hang_Extra sections = lent.ext_get(); + CHECK(catalog->mutate([&](moq::borrow lent) { + moq::mux_catalog_hang_Extra sections = lent->ext_get(); CHECK(sections.set("app", std::move(*app))); - lent.ext_set(std::move(sections)); + lent->ext_set(std::move(sections)); })); - CHECK(catalog->mutate([&](moq::hang_Catalog_Extra &lent) { - auto json = lent.to_json(); + CHECK(catalog->mutate([&](moq::borrow lent) { + auto json = lent->to_json(); CHECK(json && json.value().str().find("\"status\"") != std::string_view::npos); - auto section = lent.ext_get().get("app"); + auto section = lent->ext_get().get("app"); CHECK(section && is(section->to_text(), R"({"v":[1,true]})")); })); // Malformed text fails to parse, and a reserved section is moq-mux's error. From c18a2c48fdfb2e5584a93ec618c86cdb6417a75b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 14:36:15 -0800 Subject: [PATCH 11/15] fix(ffi): sort driver.rs's imports Co-Authored-By: Claude Opus 5.5 --- ffi/src/driver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 43d123835..79faaa871 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -10,9 +10,9 @@ use rustc_driver::{Callbacks, Compilation}; use rustc_hir::def::{DefKind, Res}; use rustc_hir::def_id::{DefId, LOCAL_CRATE}; use rustc_interface::interface::Compiler; +use rustc_lexer::{FrontmatterAllowed, TokenKind}; use rustc_middle::middle::resolve::Reexport; use rustc_middle::ty::TyCtxt; -use rustc_lexer::{FrontmatterAllowed, TokenKind}; use rustc_parse::lexer::StripTokens; use rustc_parse::parser::{AllowConstBlockItems, ForceCollect}; use rustc_span::{FileName, Span, sym}; From 7064cc2a468b4d5aef43608731afb7b603653867 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 14:44:02 -0800 Subject: [PATCH 12/15] fix(ffi): end a cfg-stripped member's type at the first balanced stop that parses, past a const generic's block Co-Authored-By: Claude Opus 5.5 --- ffi/src/driver.rs | 75 ++++++++++++++++++++++++++----------------- ffi/tests/fixtures.rs | 8 ++--- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 79faaa871..f2df5101e 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -219,47 +219,40 @@ fn stripped_member(tcx: TyCtxt<'_>, adt: DefId, name: &str) -> bool { { return false; } - // The item from its name on, up to the `;` or closing delimiter that ends it: past it the - // file may close a module the item is in, which the parser would refuse unmatched. + // The item from its name on, up to the `;` or closing brace that ends it: past it the + // file may close a module the item is in, which the parser would refuse unmatched. Each + // place nesting returns to the item's own level may end it, such as a const generic's + // block before its fields, so the first that parses as an item does. let source_map = tcx.sess.source_map(); let head = tcx.def_span(adt); let source_file = source_map.lookup_source_file(head.lo()); - let Ok(mut source) = source_map.span_to_snippet(head.with_hi(source_file.end_position())) else { + let Ok(source) = source_map.span_to_snippet(head.with_hi(source_file.end_position())) else { return false; }; let (mut depth, mut at) = (0usize, 0usize); + let mut item = None; for token in rustc_lexer::tokenize(&source, FrontmatterAllowed::No) { at += token.len as usize; - match token.kind { - TokenKind::OpenParen | TokenKind::OpenBrace | TokenKind::OpenBracket => depth += 1, + let end = match token.kind { + TokenKind::OpenParen | TokenKind::OpenBrace | TokenKind::OpenBracket => { + depth += 1; + false + } TokenKind::CloseParen | TokenKind::CloseBrace | TokenKind::CloseBracket => { - depth = depth.saturating_sub(1); - if depth == 0 && token.kind == TokenKind::CloseBrace { - break; - } + // A delimiter the item never opened closes the module it is in. + let Some(inner) = depth.checked_sub(1) else { break }; + depth = inner; + depth == 0 && token.kind == TokenKind::CloseBrace } - TokenKind::Semi if depth == 0 => break, - _ => {} + TokenKind::Semi => depth == 0, + _ => false, + }; + if end && let Some(parsed) = parse_item(tcx, &source[..at]) { + item = Some(parsed); + break; } } - source.truncate(at); - let file = FileName::anon_source_code(&source); - let mut parser = match rustc_parse::new_parser_from_source_str(&tcx.sess.psess, file, source, StripTokens::Nothing) - { - Ok(parser) => parser, - Err(errors) => { - errors.into_iter().for_each(|e| e.cancel()); - return false; - } - }; - let item = match parser.parse_item(ForceCollect::No, AllowConstBlockItems::DoesNotMatter) { - Ok(Some(item)) => item, - Ok(None) => return false, - Err(e) => { - e.cancel(); - return false; - } - }; + let Some(item) = item else { return false }; let configured = |attrs: &[ast::Attribute]| attrs.iter().any(|attr| attr.has_name(sym::cfg)); match &item.kind { ast::ItemKind::Struct(_, _, data) | ast::ItemKind::Union(_, _, data) => { @@ -272,6 +265,30 @@ fn stripped_member(tcx: TyCtxt<'_>, adt: DefId, name: &str) -> bool { } } +/// `source` parsed as one item, or none if it is not one. +fn parse_item(tcx: TyCtxt<'_>, source: &str) -> Option> { + let file = FileName::anon_source_code(source); + let mut parser = match rustc_parse::new_parser_from_source_str( + &tcx.sess.psess, + file, + source.to_string(), + StripTokens::Nothing, + ) { + Ok(parser) => parser, + Err(errors) => { + errors.into_iter().for_each(|e| e.cancel()); + return None; + } + }; + match parser.parse_item(ForceCollect::No, AllowConstBlockItems::DoesNotMatter) { + Ok(item) => item, + Err(e) => { + e.cancel(); + None + } + } +} + /// Whether `path`, from the facade's root, names an item a `cfg` leaves out of this build, /// or a field or variant of one: the config names it for the builds that have it. Its first /// segment no module has is stripped from that module or from one the module diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index d0a740961..b6408634d 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -1489,9 +1489,9 @@ fn foreign_role_names() { /// An exclude may name what a `cfg` leaves out of this build, whether the facade's own /// module or another crate's that the facade re-exports through a glob, or a chain of them, -/// or a field or variant of the facade's or another crate's type, an inline module's -/// included, but not what no build has, nor what a module only some of whose items the -/// facade re-exports leaves out. +/// or a field or variant of the facade's or another crate's type, an inline module's or a +/// const-generic one's included, but not what no build has, nor what a module only some +/// of whose items the facade re-exports leaves out. #[test] fn configured_out() { let package = package("configured_out"); @@ -1510,7 +1510,7 @@ fn configured_out() { "pub use ::dep::Size;\n\npub mod dep {\n\tpub use ::dep::*;\n}\n\n#[cfg(any())]\npub mod local {}\n\n\ mod inner {\n\tpub fn kept() -> u32 {\n\t\t1\n\t}\n\n\t#[cfg(any())]\n\tpub mod gone {}\n}\n\n\ mod middle {\n\tpub use crate::inner::*;\n}\n\npub mod outer {\n\tpub use crate::middle::*;\n}\n\n\ - pub struct Shape {\n\tpub n: u32,\n\t#[cfg(any())]\n\tpub gone: u32,\n}\n\n\ + pub struct Shape {\n\tpub n: u32,\n\t#[cfg(any())]\n\tpub gone: u32,\n}\n\n\ pub enum Kind {\n\tKept,\n\t#[cfg(any())]\n\tGone,\n}\n\n\ pub mod nested {\n\tpub struct Inner {\n\t\tpub n: u32,\n\t\t#[cfg(any())]\n\t\tpub gone: u32,\n\t}\n}\n", ), From f94e2c2a137563b8a7aae24da1326248b72204f5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 15:27:54 -0800 Subject: [PATCH 13/15] fix(ffi): name every Kotlin local apart from the function's parameters, writers included Co-Authored-By: Claude Opus 5.5 --- ffi/fixtures/api/src/lib.rs | 6 ++++++ ffi/src/kotlin.rs | 22 ++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/ffi/fixtures/api/src/lib.rs b/ffi/fixtures/api/src/lib.rs index 35d8ef2d7..bd3123e0e 100644 --- a/ffi/fixtures/api/src/lib.rs +++ b/ffi/fixtures/api/src/lib.rs @@ -1894,6 +1894,12 @@ pub fn put_number(value: u32, buf: &mut impl bytes::BufMut) { buf.put_slice(format!("{value},").as_bytes()); } +/// `w235` a second later: a parameter named as Kotlin's generated writer of it would be, +/// whose name the writer must take apart from it. +pub fn second_later(w235: Duration) -> Duration { + w235.saturating_add(Duration::from_secs(1)) +} + /// Takes the first byte of `r` as a decimal digit, as moq's decoders take from an /// `impl Buf`, which takes `Bytes`. It takes the byte even when it fails, and Rust writes /// back what the call left on either path. Named `r`, as moq's `varint::decode_quic`'s diff --git a/ffi/src/kotlin.rs b/ffi/src/kotlin.rs index 5fd8baa0d..a427355b3 100644 --- a/ffi/src/kotlin.rs +++ b/ffi/src/kotlin.rs @@ -52,6 +52,7 @@ pub fn generate(abi: &Abi<'_>, packages: &str, contract: u64) -> Result { errors: Vec, /// A counter for fresh Kotlin names. fresh: usize, + /// The Kotlin parameters of the function being written, which a fresh name never takes. + params: Vec, /// The arities a tuple class is written for: one, and past `Triple`. tuples: BTreeSet, /// The Kotlin functions the glue calls, which the rules keep. @@ -303,15 +306,10 @@ struct Gen<'a> { impl<'a> Gen<'a> { fn fresh(&mut self, base: &str) -> String { - self.fresh += 1; - format!("{base}{}", self.fresh) - } - - /// A fresh Kotlin name that is none of `params`, for a local in their scope. - fn fresh_beside(&mut self, base: &str, params: &[String]) -> String { loop { - let name = self.fresh(base); - if !params.contains(&name) { + self.fresh += 1; + let name = format!("{base}{}", self.fresh); + if !self.params.contains(&name) { return name; } } @@ -1443,6 +1441,7 @@ impl<'a> Gen<'a> { place: Place, ) -> Option { let abi = self.abi; + self.params = locals(&function.params); let export = abi.export(function, owner); let c_name = export.call.name.clone(); let bare = self.bare(&c_name).to_string(); @@ -1504,7 +1503,7 @@ impl<'a> Gen<'a> { } let mut kt_params = Vec::new(); let mut closures = Vec::new(); - let names = locals(&function.params); + let names = self.params.clone(); // Each parameter Rust writes back, by its Kotlin name, and what it holds. let mut written: Vec<(String, Type)> = Vec::new(); for (param, name) in function.params.iter().zip(&names) { @@ -1550,7 +1549,7 @@ impl<'a> Gen<'a> { .collect(); let mut exprs: Vec = args.iter().flat_map(|a| a.exprs.clone()).collect(); // A future's call also takes the signal its waker parks, if its first poll pends. - let signal = export.future.is_some().then(|| self.fresh_beside("s", &names)); + let signal = export.future.is_some().then(|| self.fresh("s")); exprs.extend(signal.clone()); let rust_pre: Vec = args.iter().flat_map(|a| a.rust_pre.clone()).collect(); let rust_args: Vec = args.iter().flat_map(|a| a.rust_args.clone()).collect(); @@ -1669,8 +1668,7 @@ impl<'a> Gen<'a> { ), ); let mut lines = Vec::new(); - // The reader, named apart from the parameters it writes back to. - let r = self.fresh_beside("r", &names); + let r = self.fresh("r"); // What Rust wrote back comes first, read whether or not the call failed. for (name, ty) in &written { let read = self.read_value(ty, &r); From a669569a2efd2f78c765c5c82f590c6e97f24b5b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:25:01 -0800 Subject: [PATCH 14/15] fix(ffi): hold and close a Kotlin or Dart method's receiver apart from parameters named handle, h, or close Co-Authored-By: Claude Opus 5.5 --- ffi/fixtures/api/src/lib.rs | 12 ++++++++++++ ffi/src/dart.rs | 3 ++- ffi/src/kotlin.rs | 8 +++++--- ffi/tests/api.kt | 2 ++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ffi/fixtures/api/src/lib.rs b/ffi/fixtures/api/src/lib.rs index bd3123e0e..b4103ebf7 100644 --- a/ffi/fixtures/api/src/lib.rs +++ b/ffi/fixtures/api/src/lib.rs @@ -117,6 +117,18 @@ impl Counter { self.value } + /// The value plus ten `handle`s and `h`: parameters named as a binding's receiver and + /// its held handle might be. + pub fn offset(&self, handle: u64, h: u64) -> u64 { + self.value + handle * 10 + h + } + + /// The value plus `close`, consuming the counter: a parameter named as the method a + /// binding closes it with might be. + pub fn into_sum(self, close: u64) -> u64 { + self.value + close + } + pub fn name(&self) -> &str { &self.name } diff --git a/ffi/src/dart.rs b/ffi/src/dart.rs index e4c87a357..f3b5c8d23 100644 --- a/ffi/src/dart.rs +++ b/ffi/src/dart.rs @@ -1702,7 +1702,8 @@ impl<'a> Gen<'a> { args.push("a_.hold(this)".to_string()); let copy = matches!(ty, Type::Object(i) if abi.api.objects[i].copy); if receiver == Receiver::Owned && !copy { - after.push("close();".to_string()); + // Qualified, apart from a parameter named `close`. + after.push("this.close();".to_string()); } } Ty::In(inner) => { diff --git a/ffi/src/kotlin.rs b/ffi/src/kotlin.rs index a427355b3..686b17638 100644 --- a/ffi/src/kotlin.rs +++ b/ffi/src/kotlin.rs @@ -1487,12 +1487,14 @@ impl<'a> Gen<'a> { n += 1; arg.jni.push("jlong"); arg.native.push("Long".to_string()); - arg.open = Some("handle.hold { h ->".to_string()); - arg.exprs.push("h".to_string()); + // Qualified, and named fresh, apart from the parameters in scope. + let h = self.fresh("h"); + arg.open = Some(format!("this.handle.hold {{ {h} ->")); + arg.exprs.push(h); arg.rust_args.push(format!("{p} as usize as _")); let copy = matches!(ty, Type::Object(i) if abi.api.objects[i].copy); if receiver == Receiver::Owned && !copy { - arg.after.push("close()".to_string()); + arg.after.push("this.close()".to_string()); } arg } diff --git a/ffi/tests/api.kt b/ffi/tests/api.kt index dc03cd845..75309b1be 100644 --- a/ffi/tests/api.kt +++ b/ffi/tests/api.kt @@ -408,8 +408,10 @@ fun script() { Counter.new("three", 3u).use { three -> println("plus ${two.plus(three)} ${two.plus(null)}") println("add_to ${two.addTo(three)} ${two.addTo(null)} ${three.get()}") + check(two.offset(3u, 4u) == 36uL, "a method's parameters named handle and h are its own") } } + check(Counter.new("five", 5u).intoSum(1u) == 6uL, "a consuming method's parameter named close is its own") val encoded = Mut("0,".encodeToByteArray()) putNumber(1u, encoded) putNumber(23u, encoded) From 2ae6f6366bcc5e8c6b07c1d98468740b4c5aae11 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:49:39 -0800 Subject: [PATCH 15/15] fix(ffi): accept an exclude of a member a cfg_attr's cfg leaves out Co-Authored-By: Claude Opus 5.5 --- ffi/src/driver.rs | 13 ++++++++++++- ffi/tests/fixtures.rs | 11 ++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index f2df5101e..291c2c1ec 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -253,7 +253,18 @@ fn stripped_member(tcx: TyCtxt<'_>, adt: DefId, name: &str) -> bool { } } let Some(item) = item else { return false }; - let configured = |attrs: &[ast::Attribute]| attrs.iter().any(|attr| attr.has_name(sym::cfg)); + // A `cfg`, or one a `cfg_attr` adds, at any depth: the attributes past its predicate. + fn adds_cfg(items: &[ast::MetaItemInner]) -> bool { + items.iter().skip(1).any(|item| { + item.has_name(sym::cfg) || (item.has_name(sym::cfg_attr) && item.meta_item_list().is_some_and(adds_cfg)) + }) + } + let configured = |attrs: &[ast::Attribute]| { + attrs.iter().any(|attr| { + attr.has_name(sym::cfg) + || (attr.has_name(sym::cfg_attr) && attr.meta_item_list().is_some_and(|items| adds_cfg(&items))) + }) + }; match &item.kind { ast::ItemKind::Struct(_, _, data) | ast::ItemKind::Union(_, _, data) => { (data.fields().iter()).any(|f| f.ident.is_some_and(|ident| ident.as_str() == name) && configured(&f.attrs)) diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 35608855a..e2778ce95 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -1490,9 +1490,10 @@ fn foreign_role_names() { /// An exclude may name what a `cfg` leaves out of this build, whether the facade's own /// module or another crate's that the facade re-exports through a glob, or a chain of them, -/// or a field or variant of the facade's or another crate's type, an inline module's or a -/// const-generic one's included, but not what no build has, nor what a module only some -/// of whose items the facade re-exports leaves out. +/// or a field or variant of the facade's or another crate's type, under a `cfg` or one a +/// `cfg_attr` adds, an inline module's type and a const-generic one's included, but not +/// what no build has, nor what a module only some of whose items the facade re-exports +/// leaves out. #[test] fn configured_out() { let package = package("configured_out"); @@ -1511,13 +1512,13 @@ fn configured_out() { "pub use ::dep::Size;\n\npub mod dep {\n\tpub use ::dep::*;\n}\n\n#[cfg(any())]\npub mod local {}\n\n\ mod inner {\n\tpub fn kept() -> u32 {\n\t\t1\n\t}\n\n\t#[cfg(any())]\n\tpub mod gone {}\n}\n\n\ mod middle {\n\tpub use crate::inner::*;\n}\n\npub mod outer {\n\tpub use crate::middle::*;\n}\n\n\ - pub struct Shape {\n\tpub n: u32,\n\t#[cfg(any())]\n\tpub gone: u32,\n}\n\n\ + pub struct Shape {\n\tpub n: u32,\n\t#[cfg(any())]\n\tpub gone: u32,\n\t#[cfg_attr(all(), cfg_attr(all(), cfg(any())))]\n\tpub gated: u32,\n}\n\n\ pub enum Kind {\n\tKept,\n\t#[cfg(any())]\n\tGone,\n}\n\n\ pub mod nested {\n\tpub struct Inner {\n\t\tpub n: u32,\n\t\t#[cfg(any())]\n\t\tpub gone: u32,\n\t}\n}\n", ), ( "ffi.toml", - "exclude = [\"Gone\", \"Kind::Gone\", \"Shape::gone\", \"Shape::missing\", \"Size::depth\", \"nested::Inner::gone\", \"dep::gone::Gone\", \"gone::Gone\", \"local::Gone\", \"outer::gone::Gone\"]\n", + "exclude = [\"Gone\", \"Kind::Gone\", \"Shape::gated\", \"Shape::gone\", \"Shape::missing\", \"Size::depth\", \"nested::Inner::gone\", \"dep::gone::Gone\", \"gone::Gone\", \"local::Gone\", \"outer::gone::Gone\"]\n", ), ], );