From 12d4e3daa98c2594d5304224d9801bb5090566e8 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sat, 8 Aug 2026 10:49:50 -0400 Subject: [PATCH 1/2] update every dependency except the egui stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the whole graph to latest: bitfield 0.14->0.19, cpal 0.15->0.18, env_logger 0.10->0.11, png 0.17->0.18, rfd 0.15->0.17, socket2 0.5->0.6, spin 0.10->0.12, libchdman-rs 0.288->0.289, windows-sys 0.52->0.61, and in iris-gui dirs 5->6, if-addrs 0.13->0.15, toml 0.8->1 (matching the root). Only 5 source lines had to move: - cpal 0.18 (hal2): SampleRate is now a plain `type = u32` alias, all error types collapsed into cpal::Error, build_output_stream takes StreamConfig by value (it's Copy, so the f32-then-i16 fallback still passes it twice), and DeviceTrait::name() is gone in favour of the Display impl. - socket2 0.6 (net): set_ttl -> set_ttl_v4, disambiguated from the IPv6 hop limit. The NAT ICMP socket is IPV4, so it's a straight substitution. - png 0.18 (iris-gui): output_buffer_size() returns Option. - rfd 0.16 dropped the async-std/tokio executor features; xdg-portal now pulls its own pollster. Also declares windows-sys Win32_Foundation, which iris needed all along and never asked for. thread_affinity's SetThreadAffinityMask/GetCurrentThread name Foundation::HANDLE, but Win32_System_Threading doesn't imply that feature — the Windows build only ever worked because rfd/socket2/ anstyle-wincon resolve to the same windows-sys and enable it themselves. Feature unification is not a contract; drop any one of them and Windows breaks in a way that reproduces nowhere else. egui/eframe stay at 0.35 and the windowing stack they share (winit, glutin, glutin-winit, glow, raw-window-handle) is untouched — winit especially, since it's patched to third_party/winit-0.30.13 for the App Store private-API fix. iris's own glow 0.13 is therefore still a second copy alongside eframe's 0.17; png used to be split the same way and is now unified. Verified: check clean across default, lightning+rex-jit+jit+chd+camera+pcap+ ultra64, r5k+r5ksc_triton+idle-pause, and iris-gui default/pcap/premiere/ appstore; 453+28 tests and 2 doctests pass; one fat-LTO release build of iris-gui --features premiere,pcap links. The chd_disk tests genuinely create and flatten compressed CHDs, so libchdman 0.289 is exercised, not just compiled. dirs 5->6 was checked to leave config_dir()/data_dir() identical on all three platforms, so gui.json doesn't move. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 25 ++++--- iris-gui/Cargo.toml | 14 ++-- iris-gui/src/main.rs | 2 +- rules/build/dependency-upgrade-gotchas.md | 91 +++++++++++++++++++++++ src/hal2.rs | 12 +-- src/net.rs | 2 +- 6 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 rules/build/dependency-upgrade-gotchas.md diff --git a/Cargo.toml b/Cargo.toml index a9917c8..2036784 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,26 +97,28 @@ ultra64 = ["dep:shared_memory", "dep:raw_sync"] [dependencies] clap = { version = "4", features = ["derive"] } log = "0.4" -env_logger = "0.10" +env_logger = "0.11" winit = "0.30" glutin = "0.32" glutin-winit = "0.5" glow = "0.13" raw-window-handle = "0.6" rtrb = "0.3" -socket2 = { version = "0.5", features = ["all"] } +socket2 = { version = "0.6", features = ["all"] } crossbeam-utils = "0.8" -bitfield = "0.14" -cpal = "0.15" +bitfield = "0.19" +cpal = "0.18" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0" toml = "1.0.3" postcard = { version = "1", features = ["alloc"] } blake3 = "1" -png = "0.17" +png = "0.18" parking_lot = "0.12" -spin = "0.10.0" -rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } +spin = "0.12" +# rfd >= 0.16 dropped the async-std/tokio runtime features: `xdg-portal` now +# pulls its own `pollster` executor to block on the portal's async D-Bus calls. +rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } gdbstub = { version = "0.7", features = ["std"] } gdbstub_arch = "0.3" cranelift-codegen = { version = "0.134", optional = true } @@ -125,11 +127,16 @@ cranelift-jit = { version = "0.134", optional = true } cranelift-module = { version = "0.134", optional = true } cranelift-native = { version = "0.134", optional = true } target-lexicon = { version = "0.13", optional = true } -libchdman-rs = { version = "0.288.9", features = ["prebuilt"], optional = true } +libchdman-rs = { version = "0.289", features = ["prebuilt"], optional = true } pcap = { version = "2", optional = true } shared_memory = { version = "0.12", optional = true } raw_sync = { version = "0.1", optional = true } -windows-sys = { version = "0.52", features = ["Win32_System_Threading"] } +# thread_affinity.rs calls SetThreadAffinityMask/GetCurrentThread, whose +# signatures name Foundation::HANDLE — so Win32_Foundation is required on top of +# Win32_System_Threading. It used to arrive by feature unification from rfd / +# socket2 / anstyle-wincon; declare it so the Windows build doesn't depend on +# who else happens to be in the graph. +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] } [target.'cfg(target_os = "macos")'.dependencies] nokhwa = { version = "0.10", features = ["input-avfoundation"], optional = true } diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index c58057b..4c99214 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -93,19 +93,21 @@ parking_lot = "0.12" # Match iris's winit version — Ps2Controller::push_kb takes # winit::keyboard::KeyCode. winit = "0.30" -png = "0.17" -rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } +png = "0.18" +# rfd >= 0.16 dropped the async-std/tokio runtime features: `xdg-portal` now +# pulls its own `pollster` executor to block on the portal's async D-Bus calls. +rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "0.8" -dirs = "5" +toml = "1" +dirs = "6" log = "0.4" -env_logger = "0.10" +env_logger = "0.11" # Read the host's own interface addresses (Networking tab: first-free subnet # presets + overlap warnings). Reads addresses via getifaddrs — no entitlement, # and it does not trigger the macOS 15 Local Network prompt (that's for talking # *to* LAN peers, not enumerating your own NICs). -if-addrs = "0.13" +if-addrs = "0.15" # macOS App Sandbox: NSURL security-scoped bookmarks let the App Store build # reopen user-selected disk images / PROMs / ISOs across launches. These crates diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index b63e322..1f3bd8b 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -37,7 +37,7 @@ fn load_icon() -> egui::IconData { const ICON_PNG: &[u8] = include_bytes!("../assets/icons/icon-256.png"); let decoder = png::Decoder::new(std::io::Cursor::new(ICON_PNG)); let mut reader = decoder.read_info().expect("decode icon PNG header"); - let mut rgba = vec![0u8; reader.output_buffer_size()]; + let mut rgba = vec![0u8; reader.output_buffer_size().expect("icon PNG size fits usize")]; let info = reader.next_frame(&mut rgba).expect("decode icon PNG pixels"); rgba.truncate(info.buffer_size()); egui::IconData { diff --git a/rules/build/dependency-upgrade-gotchas.md b/rules/build/dependency-upgrade-gotchas.md new file mode 100644 index 0000000..8195082 --- /dev/null +++ b/rules/build/dependency-upgrade-gotchas.md @@ -0,0 +1,91 @@ +# Dependency upgrade gotchas (2026-08 sweep) + +Notes from the crate-update pass that moved iris off `bitfield 0.14 / cpal 0.15 +/ env_logger 0.10 / png 0.17 / rfd 0.15 / socket2 0.5 / spin 0.10 / +windows-sys 0.52 / libchdman-rs 0.288` and iris-gui off `dirs 5 / if-addrs 0.13 +/ toml 0.8`. Only the non-obvious parts are recorded here. + +## windows-sys: `Win32_Foundation` was arriving by accident + +`src/thread_affinity.rs` calls `SetThreadAffinityMask`/`GetCurrentThread`, and +both signatures name `Foundation::HANDLE`. windows-sys gates that module behind +the **`Win32_Foundation`** feature, which `Win32_System_Threading` does *not* +imply — in 0.52 the generated binding even carried a +`#[doc = "Required features: \"Win32_Foundation\""]` marker. + +iris only ever declared `Win32_System_Threading`. The Windows build worked +purely because `rfd`, `socket2`, and `anstyle-wincon` resolve to the *same* +windows-sys version and enable `Win32_Foundation` themselves, so Cargo's feature +unification filled the gap. That is a build that depends on who else happens to +be in the graph — drop or re-version any of those crates and the Windows build +breaks in a way that reproduces on no other platform. + +`Cargo.toml` now declares both features explicitly. **Don't "simplify" that back +down to one feature.** + +macOS/Linux dev boxes cannot catch this: `windows-sys` is a hard (non +target-gated) dependency, so Cargo *validates the feature names* everywhere, but +the module itself only compiles on Windows. Cargo accepting the manifest proves +nothing about the Windows build. + +## cpal 0.18 API churn (`src/hal2.rs`) + +Four unrelated breaks in the audio-output path: + +- `SampleRate` is now `pub type SampleRate = u32` — a plain alias, not a tuple + struct. `cpal::SampleRate(rate)` → `rate`. +- All error types collapsed into one `cpal::Error` + `ErrorKind`. The stream + error callback takes `cpal::Error`; `cpal::StreamError` is gone. +- `build_output_stream` takes `config: StreamConfig` **by value**. `StreamConfig` + is `Copy`, so the f32-then-i16 fallback can still pass the same config twice. +- `DeviceTrait::name()` is gone. `DeviceTrait: Display`, so the device name is + `device.to_string()` / `{}`. (`description()` returns the structured + `DeviceDescription` if more than the name is ever needed.) + +`HostTrait` lost `id()`, but the concrete `cpal::Host` keeps an inherent `id()`, +so `host.id()` still compiles. + +## rfd ≥ 0.16 dropped the async-runtime features + +`rfd 0.15` took `features = ["xdg-portal", "async-std"]`. 0.16+ removed the +`async-std`/`tokio` executor features outright; `xdg-portal` now implies its own +`pollster` executor. The manifest is just `features = ["xdg-portal"]` — listing +`pollster` alongside it is redundant. + +rfd also gained a `wayland` feature (for parenting dialogs to a Wayland surface). +iris calls no `set_parent`, so it stays off, and this is not a regression: the +old `default-features = false` config never had an equivalent either. + +## Smaller renames + +- **socket2 0.6**: `Socket::set_ttl` → `set_ttl_v4` (disambiguated from the IPv6 + hop limit). `src/net.rs` sets it on an `IPV4` ICMP socket, so the rename is a + straight substitution. +- **png 0.18**: `Reader::output_buffer_size()` returns `Option` (`None` + when the size would overflow `usize`). + +## Verified drop-ins — don't re-derive these + +- **`dirs` 5 → 6 does not move any user data.** `config_dir()` and `data_dir()` + are byte-identical in both versions on Linux (XDG), macOS (Application + Support), and Windows (Roaming AppData). iris-gui's `gui.json` machine store + and `iris-gui.pid` stay exactly where they were; no migration needed. +- **`bitfield` 0.14 → 0.19 is a clean drop-in** despite five major versions. + All eight `bitfield! { ... }` blocks (rex3, vc2, mips_cache_v2, hal2, + saa7191, mips_exec) compile untouched. +- **`libchdman-rs` 0.288 → 0.289** needs no code change. The `chd_disk` tests + exercise it for real — they create compressed CHDs, write COW diffs, and + flatten them — so a green `cargo test --features chd chd_disk` is meaningful + coverage of the upgrade, not just a compile check. + +## What this sweep deliberately left alone + +`egui`/`eframe` (0.35, though 0.36.1 exists) and the whole windowing stack that +shares crates with them: `winit`, `glutin`, `glutin-winit`, `glow`, +`raw-window-handle`. `winit` in particular is `[patch.crates-io]`-ed to +`third_party/winit-0.30.13` for the App Store private-API fix — see +`rules/macos/appstore-private-api.md` before touching it. + +Side effect worth knowing: iris's own `glow` (0.13) and egui's (0.17) are still +two versions in the lockfile. `png` used to be split the same way and is now +unified on 0.18. diff --git a/src/hal2.rs b/src/hal2.rs index b0a5ce8..9e90c38 100644 --- a/src/hal2.rs +++ b/src/hal2.rs @@ -311,11 +311,11 @@ fn open_persistent_output(underruns: Arc, playing: Arc) - for &rate in PREFERRED_RATES { let config = cpal::StreamConfig { channels: 2, - sample_rate: cpal::SampleRate(rate), + sample_rate: rate, buffer_size: cpal::BufferSize::Default, }; let ring_size = prebuf_samples(rate) * RING_BUF_MULTIPLIER; - let err_fn = |err: cpal::StreamError| { eprintln!("HAL2: cpal stream error: {:?}", err); }; + let err_fn = |err: cpal::Error| { eprintln!("HAL2: cpal stream error: {:?}", err); }; // Try f32 first (macOS CoreAudio native), then i16 (Linux ALSA). let (producer, stream) = { @@ -335,7 +335,7 @@ fn open_persistent_output(underruns: Arc, playing: Arc) - }; } }; - match device.build_output_stream(&config, data_fn, err_fn.clone(), None) { + match device.build_output_stream(config, data_fn, err_fn.clone(), None) { Ok(s) => (p, s), Err(_) => { // f32 failed, try i16 @@ -355,7 +355,7 @@ fn open_persistent_output(underruns: Arc, playing: Arc) - }; } }; - match device.build_output_stream(&config, data_fn, err_fn.clone(), None) { + match device.build_output_stream(config, data_fn, err_fn.clone(), None) { Ok(s) => (p, s), Err(e) => { eprintln!("HAL2: cpal build_output_stream failed at {}Hz: {:?}", rate, e); @@ -366,8 +366,8 @@ fn open_persistent_output(underruns: Arc, playing: Arc) - } }; if stream.play().is_err() { continue; } - println!("HAL2: audio output: {:?} via {:?} at {}Hz", - device.name().unwrap_or_default(), host.id(), rate); + // cpal 0.18 dropped DeviceTrait::name(); a Device's Display impl is its name. + println!("HAL2: audio output: {} via {:?} at {}Hz", device, host.id(), rate); return Some(AudioOut { stream_rate: rate, producer, diff --git a/src/net.rs b/src/net.rs index 7ace23b..3aa0fcd 100644 --- a/src/net.rs +++ b/src/net.rs @@ -1524,7 +1524,7 @@ impl NatEngine { // at the right hop count. On Windows (SOCK_RAW) and macOS (SOCK_DGRAM) those // replies arrive back on this socket and we forward them to the guest. On Linux they // are silently dropped by the kernel — traceroute sees * * *. - let _ = sock.set_ttl(ttl as u32); + let _ = sock.set_ttl_v4(ttl as u32); let dest = SocketAddr::new(IpAddr::V4(dst_ip), 0); let _ = sock.send_to(payload, &dest.into()); } From 9f26ba452d1bc4112a75b30954f1ef807164b397 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sat, 8 Aug 2026 11:09:01 -0400 Subject: [PATCH 2/2] scripts: emit a per-build crate manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.lock is gitignored, so every CI run re-resolves the whole graph and a build that was green yesterday can go red today because some transitive crate five levels down shipped a new minor. Nothing in the diff shows it, and bisecting doesn't help — checking out an old commit doesn't restore that commit's dependency set. build-manifest.sh records what a specific build actually resolved: git commit + dirty state, host triple, rustc/cargo versions, the cargo selection flags, and every crate version in the graph. Ship it as a release artifact and that class of failure becomes a diff of two manifests. It takes the build's own selection flags after `--`, because the resolve is feature-dependent — the default build pulls 154 crates, lightning+jit+chd+ camera+pcap+ultra64 pulls 208. Run it after the build, in the same job, or it describes a resolve that never shipped. Two details worth keeping: - The trailing sha256 is over the crate list alone, so it's a stable identity for the dependency graph. Same fingerprint across two releases => deps are provably identical and the regression is elsewhere. - Non-registry sources get their own section. A version number alone doesn't identify the code that shipped when [patch.crates-io] is in play, and it is here: winit resolves to third_party/winit-0.30.13, not the crates.io 0.30.13. Workspace members and cargo's (proc-macro) marker are filtered out so that section shows only genuine overrides. Not wired into any workflow — release.yml lives on main by the file-ownership rule, so the pipeline change goes there separately. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/build-manifest.sh | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100755 scripts/build-manifest.sh diff --git a/scripts/build-manifest.sh b/scripts/build-manifest.sh new file mode 100755 index 0000000..6d5cbbc --- /dev/null +++ b/scripts/build-manifest.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Emit a build manifest: every crate version that went into a specific build, +# plus the toolchain and git state that produced it. +# +# Why: Cargo.lock is gitignored (see rules/build/dependency-upgrade-gotchas.md), +# so every CI run re-resolves the whole graph from scratch. A build that was +# green yesterday can go red today because a transitive crate five levels down +# shipped a new minor — and nothing in the diff shows it. Shipping this file +# with each release turns that class of failure into a `diff` of two manifests. +# +# Usage — pass the SAME cargo selection flags the build used, after `--`: +# +# ./scripts/build-manifest.sh -o dist/manifest.txt -- -p iris-gui --features premiere,pcap +# ./scripts/build-manifest.sh -o dist/manifest.txt -- --features lightning,rex-jit,chd +# ./scripts/build-manifest.sh # defaults: root pkg, default features, stdout +# +# Run it AFTER the build, in the same job, on the same machine. It reads the +# Cargo.lock the build just produced; run it before, or with different feature +# flags, and it describes a resolve that never shipped. +# +# The fingerprint at the bottom is a sha256 of the crate list alone. Two +# releases with the same fingerprint had byte-identical dependency graphs, so a +# behaviour difference between them is NOT a dependency change — look +# elsewhere. Different fingerprints: `diff` the two manifests and the culprit +# is on the changed line. + +set -euo pipefail + +OUT="" +CARGO_ARGS=() + +while [ $# -gt 0 ]; do + case "$1" in + -o|--output) OUT="$2"; shift 2 ;; + --) shift; CARGO_ARGS=("$@"); break ;; + -h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1 (cargo flags go after --)" >&2; exit 2 ;; + esac +done + +# cargo has to run from the workspace, but a relative -o should still land where +# the caller expects, not silently under the repo root. +case "${OUT:-}" in + ""|/*) ;; + *) OUT="$PWD/$OUT" ;; +esac +cd "$(dirname "$0")/.." + +# --- git state --------------------------------------------------------------- +if git rev-parse --git-dir >/dev/null 2>&1; then + GIT_COMMIT=$(git rev-parse --short HEAD) + GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ -n "$(git status --porcelain)" ]; then + GIT_STATE="DIRTY (uncommitted changes — this build is not reproducible from git)" + else + GIT_STATE="clean" + fi +else + GIT_COMMIT="(not a git checkout)"; GIT_BRANCH="-"; GIT_STATE="-" +fi + +# --- what was actually selected ---------------------------------------------- +# cargo tree resolves for the host target unless --target is passed through, and +# honours -p / --features exactly as cargo build does. Keeping the args verbatim +# is what makes this manifest describe THIS build rather than some other one. +HOST=$(rustc -vV | awk '/^host:/ {print $2}') +SELECTION="${CARGO_ARGS[*]:-(none — root package, default features)}" + +# {p} renders "name vX.Y.Z" for registry crates and appends the path/git source +# for everything else, so [patch.crates-io] entries (the vendored winit) are +# visible rather than masquerading as the crates.io release. +# The ${a[@]+"${a[@]}"} dance keeps `set -u` happy on bash 3.2 (what macOS +# ships) when no cargo args were passed — a plain "${a[@]}" is an unbound- +# variable error there, and "${a[@]:-}" would smuggle in an empty argument. +TREE=$(cargo tree --edges normal --prefix none --format '{p}' \ + ${CARGO_ARGS[@]+"${CARGO_ARGS[@]}"} 2>/dev/null \ + | sed 's/ (\*)$//' | grep -v '^[[:space:]]*$' | sort -u) + +CRATES=$(printf '%s\n' "$TREE" | sed -E 's/^([^ ]+) v([^ ]+).*/\1 \2/' | sort -u) + +# Non-registry = a path or URL source in the trailing parens. Excludes the +# "(proc-macro)" marker cargo also renders there, and excludes this workspace's +# own members (always path deps, never interesting) so the section shows only +# genuine overrides — the [patch.crates-io] winit, or any git dependency. +# --no-deps limits `packages` to workspace members. The "name" -> "version" key +# pair is unique to a package entry; dependency entries pair "name" with +# "source"/"req", so this can't accidentally match one. +MEMBERS=$(cargo metadata --no-deps --format-version 1 2>/dev/null \ + | grep -o '"name":"[^"]*","version":"' | sed -E 's/"name":"([^"]*)".*/\1/' || true) +NONREG=$(printf '%s\n' "$TREE" | grep -E ' \((/|[a-z+]+://)' || true) +for m in $MEMBERS; do + NONREG=$(printf '%s\n' "$NONREG" | grep -v "^${m} v" || true) +done +NONREG=$(printf '%s\n' "$NONREG" | grep -v '^[[:space:]]*$' || true) + +COUNT=$(printf '%s\n' "$CRATES" | grep -c '' || true) +FINGERPRINT=$(printf '%s\n' "$CRATES" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d' ' -f1) + +# --- emit -------------------------------------------------------------------- +emit() { +cat < identical dependency graph, so any +behaviour change between them came from iris's own code or the toolchain, not +from a crate update. Different => diff the two manifests to find which crate +moved. +EOF +} + +if [ -n "$OUT" ]; then + mkdir -p "$(dirname "$OUT")" + emit > "$OUT" + echo "wrote $OUT ($COUNT crates, fingerprint ${FINGERPRINT:0:12})" +else + emit +fi