From d2c97f01284ea148e9e57d261be8615deccd3936 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 14:47:45 -0400 Subject: [PATCH 01/12] docs(platform): support macOS 14 and newer --- DESIGN.md | 8 ++++++-- .../2026-07-19-portable-platform-architecture-design.md | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 05bdcd5c..f355605f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,7 +23,11 @@ Per-project versions are supported too — add a pv.yml file with php: "8.4" in ## Platform Scope -PV v1 targets macOS 13 and newer. Stabilizing the macOS application remains the immediate product priority. +PV v1 supports macOS 14 and newer. Stabilizing the macOS application remains the immediate product priority. + +macOS 13 may continue to run PV when the application and Managed Resource binaries remain compatible, but it is untested and unsupported. Dropping support does not by itself require raising binary deployment targets or republishing otherwise compatible Managed Resource artifacts. Before PV deliberately ships an application binary that cannot run on macOS 13, the application update manifest and updater must prevent an incompatible update from being activated there. + +For private macOS interfaces such as listener inspection, acceptance covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. New supported macOS major versions must be added to the matrix before PV relies on private interface behavior there. Linux and Windows are committed subsequent platforms. During macOS stabilization, the installed application and runtime crates compile natively on macOS, Linux, and Windows so new system boundaries do not create unnecessary portability blockers. @@ -893,7 +897,7 @@ PV v1 relies on HTTPS trust for the artifact manifest itself plus SHA-256 verifi Public v1 should support separate Managed Resource artifacts for both Apple Silicon and Intel macOS: `darwin-arm64` and `darwin-amd64`. PV v1 does not use universal macOS Managed Resource artifacts. If build complexity blocks progress, Apple Silicon-only is acceptable for an initial preview, but not as the intended public v1 scope. -Managed Resource artifact recipes set an explicit macOS deployment target of macOS 13.0 unless a later design decision raises PV's minimum supported macOS version. Recipes must not silently inherit a newer GitHub runner deployment target. +Managed Resource artifact recipes retain an explicit macOS deployment target of macOS 13.0 even though PV supports macOS 14 and newer. Keeping otherwise compatible artifacts runnable on macOS 13 does not make that operating system supported. Recipes must not silently inherit a newer GitHub runner deployment target, and raising an artifact deployment target requires a separate compatibility decision. The artifact manifest may use `platform: "any"` only for truly portable artifacts that do not contain platform-specific binaries. Composer is the expected v1 `platform: "any"` artifact because PV packages `composer.phar` inside a PV-owned archive. Native Managed Resource artifacts use explicit platform values such as `darwin-arm64` or `darwin-amd64`. diff --git a/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md b/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md index ede2f620..4ddc4e10 100644 --- a/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md +++ b/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md @@ -12,7 +12,7 @@ The implementation will use compile-time-selected operating-system modules behin The implementation must update `DESIGN.md` to record these decisions: -- PV v1 targets macOS 13 and newer. +- PV v1 supports macOS 14 and newer. macOS 13 may remain binary-compatible, but it is untested and unsupported. - Stabilizing the macOS application remains the immediate product priority. - Linux and Windows are committed subsequent platforms. - New system boundaries must avoid unnecessary portability blockers. @@ -278,7 +278,7 @@ These phases define dependency and review order. They should be split into indep - Detect IPv4 loopback, IPv6 loopback, IPv4 wildcard, and IPv6 wildcard listeners relevant to PV. - Report TCP listen state and the owning process evidence when the operating system makes it available. - Define deterministic behavior for permission limitations and processes that exit during inspection. -- Pass fixtures on supported macOS versions and both supported macOS architectures before replacing production inspection. +- Pass fixtures on every supported macOS major version and both supported macOS architectures across the representative matrix defined in `DESIGN.md` before replacing production inspection. - Remove both `netstat-esr` and `/usr/sbin/netstat` together after acceptance; do not add a third permanent source. ### Lifecycle Candidates From 9358d517d8fa3ae0ca05c3ecb639156d135b0525 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 14:47:52 -0400 Subject: [PATCH 02/12] feat(platform): cut over macOS listener inspection --- .github/workflows/ci.yml | 51 ++++++++ Cargo.lock | 12 -- Cargo.toml | 1 - crates/platform/Cargo.toml | 1 - crates/platform/src/ca.rs | 5 +- crates/platform/src/error.rs | 19 +-- crates/platform/src/lib.rs | 23 ---- crates/platform/src/listener.rs | 3 - crates/platform/src/listener/macos.rs | 111 +----------------- .../src/listener/macos/kernel_table.rs | 60 ++++++++-- ...tects_all_controlled_listener_classes.snap | 4 + ...overs_loopback_and_wildcard_addresses.snap | 10 -- scripts/ci/privileged-macos-rc.sh | 2 +- 13 files changed, 116 insertions(+), 186 deletions(-) delete mode 100644 crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c279072..17a270aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,57 @@ jobs: - name: Run tests run: cargo nextest run --workspace --all-features --locked + listener-acceptance: + name: Listener acceptance (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: macOS 14 arm64 + runner: macos-14 + macos_major: "14" + architecture: arm64 + - name: macOS 15 Intel + runner: macos-15-intel + macos_major: "15" + architecture: x86_64 + - name: macOS 26 arm64 + runner: macos-26 + macos_major: "26" + architecture: arm64 + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Verify acceptance host + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + EXPECTED_MACOS_MAJOR: ${{ matrix.macos_major }} + run: | + test "$(sw_vers -productVersion | cut -d. -f1)" = "$EXPECTED_MACOS_MAJOR" + test "$(uname -m)" = "$EXPECTED_ARCHITECTURE" + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + + - name: Install cargo-nextest + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest + + - name: Compare listener inspection with Apple netstat + run: >- + cargo nextest run -p platform --all-features --locked + --run-ignored ignored-only + -E 'test(live_kernel_table_repeatedly_detects_all_controlled_listener_classes)' + runtime-portability: name: Runtime (${{ matrix.target }}) strategy: diff --git a/Cargo.lock b/Cargo.lock index cacf8052..0c9132a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2191,17 +2191,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "netstat-esr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db13bc9e63aaed8fbd195b1835c1bdcd5d4bc834ba62f3bef459e88c00f0d08" -dependencies = [ - "bitflags 1.3.2", - "cc", - "libc", -] - [[package]] name = "nix" version = "0.31.3" @@ -2423,7 +2412,6 @@ dependencies = [ "getrandom 0.4.2", "insta", "libc", - "netstat-esr", "plist", "rcgen", "rustix", diff --git a/Cargo.toml b/Cargo.toml index 066d09ee..bcee5a13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,6 @@ indicatif = { version = "0.18.6", default-features = false, features = ["unicode insta = { version = "1", features = ["filters"] } libc = "0.2" linemux = "0.3.0" -netstat = { package = "netstat-esr", version = "0.8.1" } object_store = { version = "0.13.2", default-features = false, features = ["aws"] } rcgen = { version = "0.14.8", features = ["x509-parser"] } redis = { version = "1.2.2", default-features = false, features = ["tokio-comp"] } diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index d6509d9b..5dd37078 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -24,7 +24,6 @@ x509-parser = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] libc = { workspace = true } -netstat = { workspace = true } rustix = { workspace = true } security-framework = { workspace = true } diff --git a/crates/platform/src/ca.rs b/crates/platform/src/ca.rs index df4dff92..8fbc2ad4 100644 --- a/crates/platform/src/ca.rs +++ b/crates/platform/src/ca.rs @@ -424,9 +424,6 @@ fn repair_reason_from_ca_error(error: PlatformError) -> CaRepairReason { | PlatformError::SystemIntegrationCommand { .. } | PlatformError::SystemIntegrationCommandStatus { .. } => CaRepairReason::InvalidCaShape, #[cfg(target_os = "macos")] - PlatformError::SocketTable(_) - | PlatformError::SocketTableCommand(_) - | PlatformError::SocketTableCommandStatus { .. } - | PlatformError::SocketTableCommandUtf8(_) => CaRepairReason::InvalidCaShape, + PlatformError::ListenerInspection { .. } => CaRepairReason::InvalidCaShape, } } diff --git a/crates/platform/src/error.rs b/crates/platform/src/error.rs index 53cd633c..86e8f507 100644 --- a/crates/platform/src/error.rs +++ b/crates/platform/src/error.rs @@ -82,18 +82,9 @@ pub enum PlatformError { SystemIntegrationCommandStatus { command: String, status: String }, #[cfg(target_os = "macos")] - #[error("could not inspect socket table: {0}")] - SocketTable(#[from] netstat::Error), - - #[cfg(target_os = "macos")] - #[error("could not run netstat for socket inspection: {0}")] - SocketTableCommand(#[source] io::Error), - - #[cfg(target_os = "macos")] - #[error("netstat socket inspection exited with {status}")] - SocketTableCommandStatus { status: String }, - - #[cfg(target_os = "macos")] - #[error("could not decode netstat socket table: {0}")] - SocketTableCommandUtf8(#[from] std::string::FromUtf8Error), + #[error("could not inspect TCP listeners: {source}")] + ListenerInspection { + #[source] + source: Box, + }, } diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index a7d017ba..6e85a1d8 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -44,13 +44,8 @@ pub use trust::{ #[cfg(test)] mod tests { - #[cfg(target_os = "macos")] - use insta::assert_debug_snapshot; - use crate::capability::require_capability_for; use crate::error::PlatformError; - #[cfg(target_os = "macos")] - use crate::listener::parse_netstat_tcp_listener_ports; use crate::{PlatformCapability, PlatformTarget}; #[test] @@ -99,22 +94,4 @@ mod tests { }) )); } - - #[test] - #[cfg(target_os = "macos")] - fn netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses() { - let output = r#" -Proto Recv-Q Send-Q Local Address Foreign Address (state) -tcp4 0 0 *.45000 *.* LISTEN -tcp4 0 0 127.0.0.1.45001 *.* LISTEN -tcp6 0 0 ::1.45002 *.* LISTEN -tcp6 0 0 ::.45003 *.* LISTEN -tcp4 0 0 192.168.1.5.45004 *.* LISTEN -tcp4 0 0 127.0.0.1.45005 127.0.0.1.12345 ESTABLISHED -udp4 0 0 127.0.0.1.45006 *.* -tcp4 0 0 127.0.0.1.notaport *.* LISTEN -"#; - - assert_debug_snapshot!(parse_netstat_tcp_listener_ports(output)); - } } diff --git a/crates/platform/src/listener.rs b/crates/platform/src/listener.rs index 74a15d8a..9c106957 100644 --- a/crates/platform/src/listener.rs +++ b/crates/platform/src/listener.rs @@ -15,9 +15,6 @@ mod implementation; #[path = "listener/windows.rs"] mod implementation; -#[cfg(all(target_os = "macos", test))] -pub(crate) use implementation::parse_netstat_tcp_listener_ports; - pub fn loopback_tcp_listener_ports() -> Result, PlatformError> { implementation::loopback_tcp_listener_ports() } diff --git a/crates/platform/src/listener/macos.rs b/crates/platform/src/listener/macos.rs index a15dece1..b60d7a1a 100644 --- a/crates/platform/src/listener/macos.rs +++ b/crates/platform/src/listener/macos.rs @@ -1,117 +1,14 @@ use std::collections::BTreeSet; -use std::net::IpAddr; - -use netstat::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, TcpState, get_sockets_info}; use crate::PlatformError; -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "kernel inspection remains acceptance-only until the supported macOS matrix passes" - ) -)] #[path = "macos/kernel_table.rs"] mod kernel_table; -#[expect( - clippy::disallowed_types, - reason = "macOS listener inspection owns read-only netstat invocation" -)] -type StdCommand = std::process::Command; - pub(super) fn loopback_tcp_listener_ports() -> Result, PlatformError> { - let mut ports = loopback_tcp_listener_ports_from_socket_table()?; - ports.extend(parse_netstat_tcp_listener_ports( - &netstat_tcp_socket_table()? - )); - - Ok(ports) -} - -fn loopback_tcp_listener_ports_from_socket_table() -> Result, PlatformError> { - let sockets = get_sockets_info( - AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, - ProtocolFlags::TCP, - )?; - let mut ports = BTreeSet::new(); - - for socket in sockets { - let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info else { - continue; - }; - - if tcp.state == TcpState::Listen && tcp_listener_address_occupies_loopback(tcp.local_addr) { - ports.insert(tcp.local_port); + kernel_table::loopback_tcp_listener_ports().map_err(|source| { + PlatformError::ListenerInspection { + source: Box::new(source), } - } - - Ok(ports) -} - -fn netstat_tcp_socket_table() -> Result { - let output = StdCommand::new("/usr/sbin/netstat") - .args(["-anv", "-p", "tcp"]) - .output() - .map_err(PlatformError::SocketTableCommand)?; - - if !output.status.success() { - return Err(PlatformError::SocketTableCommandStatus { - status: output.status.to_string(), - }); - } - - Ok(String::from_utf8(output.stdout)?) -} - -pub(crate) fn parse_netstat_tcp_listener_ports(output: &str) -> BTreeSet { - let mut ports = BTreeSet::new(); - - for line in output.lines() { - let columns = line.split_whitespace().collect::>(); - let [ - protocol, - _recv_queue, - _send_queue, - local_address, - _foreign_address, - state, - .., - ] = columns.as_slice() - else { - continue; - }; - - if !protocol.starts_with("tcp") || *state != "LISTEN" { - continue; - } - - if let Some(port) = loopback_port_from_netstat_local_address(local_address) { - ports.insert(port); - } - } - - ports -} - -fn loopback_port_from_netstat_local_address(local_address: &str) -> Option { - let (address, port) = local_address.rsplit_once('.')?; - let port = port.parse::().ok()?; - - if address == "*" { - return Some(port); - } - - let address = address.parse::().ok()?; - - if tcp_listener_address_occupies_loopback(address) { - Some(port) - } else { - None - } -} - -fn tcp_listener_address_occupies_loopback(address: IpAddr) -> bool { - address.is_loopback() || address.is_unspecified() + }) } diff --git a/crates/platform/src/listener/macos/kernel_table.rs b/crates/platform/src/listener/macos/kernel_table.rs index 9e1d1faa..486902c9 100644 --- a/crates/platform/src/listener/macos/kernel_table.rs +++ b/crates/platform/src/listener/macos/kernel_table.rs @@ -32,7 +32,7 @@ const INPCB_IPV4_ADDRESS_OFFSET: usize = INPCB_LOCAL_ADDRESS_OFFSET + 12; const TCPCB_STATE_OFFSET: usize = 36; #[derive(Debug, Error)] -enum KernelTableError { +pub(super) enum KernelTableError { #[error(transparent)] Fetch(#[from] FetchError), @@ -44,7 +44,7 @@ enum KernelTableError { } #[derive(Debug, Error)] -enum FetchError { +pub(super) enum FetchError { #[error("could not query the macOS TCP PCB table size: {0}")] Size(#[source] io::Error), @@ -61,7 +61,7 @@ enum FetchError { } #[derive(Debug, Error, Eq, PartialEq)] -enum ParseError { +pub(super) enum ParseError { #[error("TCP PCB table is too short: expected at least {minimum} bytes, received {actual}")] TableTooShort { minimum: usize, actual: usize }, @@ -130,7 +130,7 @@ struct InternetPcb { local_address: [u8; INPCB_LOCAL_ADDRESS_LENGTH], } -fn loopback_tcp_listener_ports() -> Result, KernelTableError> { +pub(super) fn loopback_tcp_listener_ports() -> Result, KernelTableError> { for _attempt in 1..=MAX_ATTEMPTS { let table = fetch_tcp_table()?; @@ -397,17 +397,18 @@ fn read_network_u16(bytes: &[u8], offset: usize) -> u16 { #[cfg(test)] mod tests { - use std::collections::VecDeque; + use std::collections::{BTreeSet, VecDeque}; use std::io; use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; use anyhow::Result; use insta::assert_debug_snapshot; + use crate::command::run_system_command_output; + use super::{ FetchError, INP_IPV4, INP_IPV6, MAX_ATTEMPTS, TCPS_LISTEN, XINPCB_MINIMUM_LENGTH, - XSO_INPCB, XSO_TCPCB, XTCPCB_MINIMUM_LENGTH, fetch_tcp_table_with, - loopback_tcp_listener_ports, parse_tcp_table, + XSO_INPCB, XSO_TCPCB, XTCPCB_MINIMUM_LENGTH, fetch_tcp_table_with, parse_tcp_table, }; const SNAPSHOT_COUNT: u32 = 8; @@ -596,14 +597,21 @@ mod tests { ("ipv6 loopback", ipv6_loopback.local_addr()?.port()), ("ipv6 wildcard", ipv6_wildcard.local_addr()?.port()), ]; - let mut detections = expected.map(|(name, _port)| (name, 0)); + let mut detections = expected.map(|(name, _port)| (name, 0, 0)); for _sample in 0..10 { - let ports = loopback_tcp_listener_ports()?; + let kernel_ports = crate::loopback_tcp_listener_ports()?; + let netstat_output = + run_system_command_output("/usr/sbin/netstat", &["-anv", "-p", "tcp"])?; + let netstat_ports = controlled_netstat_listener_ports(&netstat_output, &expected); + for (index, (_name, port)) in expected.iter().enumerate() { - if ports.contains(port) { + if kernel_ports.contains(port) { detections[index].1 += 1; } + if netstat_ports.contains(port) { + detections[index].2 += 1; + } } } @@ -612,6 +620,38 @@ mod tests { Ok(()) } + fn controlled_netstat_listener_ports(output: &str, expected: &[(&str, u16)]) -> BTreeSet { + output + .lines() + .filter_map(|line| { + let columns = line.split_whitespace().collect::>(); + let [ + protocol, + _recv_queue, + _send_queue, + local_address, + _foreign_address, + state, + .., + ] = columns.as_slice() + else { + return None; + }; + if !protocol.starts_with("tcp") || *state != "LISTEN" { + return None; + } + + let (_address, port) = local_address.rsplit_once('.')?; + let port = port.parse::().ok()?; + + expected + .iter() + .any(|(_name, expected_port)| *expected_port == port) + .then_some(port) + }) + .collect() + } + #[derive(Debug)] enum QueryStep { Size(usize), diff --git a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap b/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap index f76f97bc..1049c3e7 100644 --- a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap +++ b/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap @@ -6,17 +6,21 @@ expression: detections ( "ipv4 loopback", 10, + 10, ), ( "ipv4 wildcard", 10, + 10, ), ( "ipv6 loopback", 10, + 10, ), ( "ipv6 wildcard", 10, + 10, ), ] diff --git a/crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap b/crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap deleted file mode 100644 index 599e4fb4..00000000 --- a/crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/platform/src/lib.rs -expression: parse_netstat_tcp_listener_ports(output) ---- -{ - 45000, - 45001, - 45002, - 45003, -} diff --git a/scripts/ci/privileged-macos-rc.sh b/scripts/ci/privileged-macos-rc.sh index 139fe0b5..558d40ed 100644 --- a/scripts/ci/privileged-macos-rc.sh +++ b/scripts/ci/privileged-macos-rc.sh @@ -202,7 +202,7 @@ record_status setup required pv setup --yes --no-path collect_file gateway-caddyfile "$HOME/.pv/config/gateway/Caddyfile" collect_file gateway-runtime-pid "$HOME/.pv/run/gateway.pid" collect_file gateway-runtime-metadata "$HOME/.pv/run/gateway.json" -record_status gateway-listeners evidence sh -c 'lsof -nP -iTCP:48080 -sTCP:LISTEN || true; netstat -anv -p tcp | grep -E "48080|48443" || true' +record_status gateway-listeners evidence lsof -nP -iTCP:48080 -sTCP:LISTEN record_status gateway-loopback-nc evidence nc -vz -G 2 127.0.0.1 48080 record_status gateway-loopback-http evidence curl --show-error --silent --max-time 5 --write-out '\nhttp_code:%{http_code}\n' http://127.0.0.1:48080/ From 04171ae813c4b591833a6036a56abf7efe26fb1c Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 20:38:17 -0400 Subject: [PATCH 03/12] ci: run full macOS support matrix --- .github/workflows/ci.yml | 71 +++++++------------ DESIGN.md | 2 +- ...9-portable-platform-architecture-design.md | 2 +- 3 files changed, 27 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17a270aa..b40538e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,51 +10,7 @@ permissions: jobs: rust: - name: Rust - runs-on: macos-14 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust build - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - - - name: Install cargo-nextest - uses: taiki-e/install-action@v2 - with: - tool: cargo-nextest - - - name: Install cargo-shear - uses: taiki-e/install-action@v2 - with: - tool: cargo-shear - - - name: Install shellcheck - run: brew install shellcheck - - - name: Check formatting - run: cargo fmt --all --check - - - name: Run Clippy - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - - - name: Check unused dependencies - run: cargo shear - - - name: Check artifact recipe scripts - run: shellcheck release/artifacts/recipes/common.sh release/artifacts/recipes/php/*.sh release/artifacts/recipes/composer/*.sh release/artifacts/recipes/redis/*.sh release/artifacts/recipes/mysql/*.sh release/artifacts/recipes/postgres/*.sh release/artifacts/recipes/mailpit/*.sh release/artifacts/recipes/rustfs/*.sh - - - name: Run tests - run: cargo nextest run --workspace --all-features --locked - - listener-acceptance: - name: Listener acceptance (${{ matrix.name }}) + name: Rust (${{ matrix.name }}) strategy: fail-fast: false matrix: @@ -79,7 +35,7 @@ jobs: with: persist-credentials: false - - name: Verify acceptance host + - name: Verify supported macOS host env: EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} EXPECTED_MACOS_MAJOR: ${{ matrix.macos_major }} @@ -98,6 +54,29 @@ jobs: with: tool: cargo-nextest + - name: Install cargo-shear + uses: taiki-e/install-action@v2 + with: + tool: cargo-shear + + - name: Install shellcheck + run: brew install shellcheck + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run Clippy + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + - name: Check unused dependencies + run: cargo shear + + - name: Check artifact recipe scripts + run: shellcheck release/artifacts/recipes/common.sh release/artifacts/recipes/php/*.sh release/artifacts/recipes/composer/*.sh release/artifacts/recipes/redis/*.sh release/artifacts/recipes/mysql/*.sh release/artifacts/recipes/postgres/*.sh release/artifacts/recipes/mailpit/*.sh release/artifacts/recipes/rustfs/*.sh + + - name: Run tests + run: cargo nextest run --workspace --all-features --locked + - name: Compare listener inspection with Apple netstat run: >- cargo nextest run -p platform --all-features --locked diff --git a/DESIGN.md b/DESIGN.md index f355605f..cc8eb998 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -27,7 +27,7 @@ PV v1 supports macOS 14 and newer. Stabilizing the macOS application remains the macOS 13 may continue to run PV when the application and Managed Resource binaries remain compatible, but it is untested and unsupported. Dropping support does not by itself require raising binary deployment targets or republishing otherwise compatible Managed Resource artifacts. Before PV deliberately ships an application binary that cannot run on macOS 13, the application update manifest and updater must prevent an incompatible update from being activated there. -For private macOS interfaces such as listener inspection, acceptance covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. New supported macOS major versions must be added to the matrix before PV relies on private interface behavior there. +The full macOS CI quality and behavior suite covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. Private-interface acceptance tests such as listener inspection run as part of every matrix lane. New supported macOS major versions must be added to the matrix before PV relies on behavior there. Linux and Windows are committed subsequent platforms. During macOS stabilization, the installed application and runtime crates compile natively on macOS, Linux, and Windows so new system boundaries do not create unnecessary portability blockers. diff --git a/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md b/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md index 4ddc4e10..20c165ed 100644 --- a/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md +++ b/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md @@ -278,7 +278,7 @@ These phases define dependency and review order. They should be split into indep - Detect IPv4 loopback, IPv6 loopback, IPv4 wildcard, and IPv6 wildcard listeners relevant to PV. - Report TCP listen state and the owning process evidence when the operating system makes it available. - Define deterministic behavior for permission limitations and processes that exit during inspection. -- Pass fixtures on every supported macOS major version and both supported macOS architectures across the representative matrix defined in `DESIGN.md` before replacing production inspection. +- Pass fixtures on every supported macOS major version and both supported macOS architectures across the full representative CI matrix defined in `DESIGN.md` before replacing production inspection. - Remove both `netstat-esr` and `/usr/sbin/netstat` together after acceptance; do not add a third permanent source. ### Lifecycle Candidates From fc2090e9f3d3e3be77a2221d4e370f9727f4c856 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 20:38:51 -0400 Subject: [PATCH 04/12] test(platform): run listener acceptance in full matrix --- .github/workflows/ci.yml | 6 ------ crates/platform/src/listener/macos/kernel_table.rs | 1 - 2 files changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b40538e3..a0649826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,12 +77,6 @@ jobs: - name: Run tests run: cargo nextest run --workspace --all-features --locked - - name: Compare listener inspection with Apple netstat - run: >- - cargo nextest run -p platform --all-features --locked - --run-ignored ignored-only - -E 'test(live_kernel_table_repeatedly_detects_all_controlled_listener_classes)' - runtime-portability: name: Runtime (${{ matrix.target }}) strategy: diff --git a/crates/platform/src/listener/macos/kernel_table.rs b/crates/platform/src/listener/macos/kernel_table.rs index 486902c9..8d9c89e3 100644 --- a/crates/platform/src/listener/macos/kernel_table.rs +++ b/crates/platform/src/listener/macos/kernel_table.rs @@ -585,7 +585,6 @@ mod tests { } #[test] - #[ignore = "acceptance-only: depends on the live macOS TCP PCB table"] fn live_kernel_table_repeatedly_detects_all_controlled_listener_classes() -> Result<()> { let ipv4_loopback = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; let ipv4_wildcard = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?; From c4f8804db1a29f33dc52818b53d3f72510079fdb Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:06:20 -0400 Subject: [PATCH 05/12] test(cli): make PHP fixtures platform deterministic --- crates/cli/tests/php.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/tests/php.rs b/crates/cli/tests/php.rs index 95abc66b..9b37fa8a 100644 --- a/crates/cli/tests/php.rs +++ b/crates/cli/tests/php.rs @@ -35,7 +35,7 @@ impl TestEnvironment { home: home.as_std_path().to_path_buf(), current_dir: RefCell::new(current_dir.as_std_path().to_path_buf()), client, - target_platform: None, + target_platform: Some(TargetPlatform::DarwinArm64), target_platform_resolution_fails: false, exec_calls: RefCell::new(Vec::new()), } From 8efbfe2198110ea2e7ca2c5160d99a8b0a39c405 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:12:41 -0400 Subject: [PATCH 06/12] test(daemon): report Mailpit fixture failures --- crates/daemon/src/managed_resources/tests.rs | 29 ++++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 0f9f0f87..60def52f 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::fmt::Debug; use std::net::TcpListener; use std::os::unix::fs::PermissionsExt; use std::sync::{Arc, Mutex}; @@ -664,7 +665,9 @@ async fn mailpit_reconciliation_records_smtp_and_dashboard_env() -> Result<()> { let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?; drop(mailpit_port_guards); - crate::project_env::reconcile_project_env(&paths, &project.id).await?; + let result = crate::project_env::reconcile_project_env(&paths, &project.id).await; + print_mailpit_fixture_log_on_error(&paths, &result); + result?; let snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -720,12 +723,14 @@ async fn mailpit_project_demand_installs_missing_fixture_track_before_start() -> let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?; drop(mailpit_port_guards); - reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url( + let result = reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url( &paths, &project.id, OFFLINE_TEST_MANIFEST_URL, ) - .await?; + .await; + print_mailpit_fixture_log_on_error(&paths, &result); + result?; let snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -1971,6 +1976,7 @@ async fn demanded_resource_cleans_runtime_files_when_process_exits_after_readine drop(mailpit_port_guards); let result = reconcile_project_env_with_fast_exit_fake_runtime_catalog(&paths, &project.id).await; + print_mailpit_fixture_log_on_error(&paths, &result); let failure_snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -4862,6 +4868,23 @@ fn runtime_files_exist(paths: &PvPaths, track: &str) -> Result(paths: &PvPaths, result: &Result) +where + E: Debug, +{ + if let Err(error) = result { + let log_path = paths.resource_log("mailpit", FAKE_MAILPIT_TRACK); + match state::fs::read_to_string(&log_path) { + Ok(log) => eprintln!("Mailpit fixture failed with {error:#?}\n{log}"), + Err(log_error) => { + eprintln!( + "Mailpit fixture failed with {error:#?}; failed to read {log_path}: {log_error}" + ); + } + } + } +} + fn runtime_files_exist_for_resource( paths: &PvPaths, resource_name: &str, From eb752a8cfe6b4fe3774d52cabaf85e94db7d622f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:16:53 -0400 Subject: [PATCH 07/12] test(daemon): attach Mailpit logs to errors --- crates/daemon/src/managed_resources/tests.rs | 58 ++++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 60def52f..8c08597c 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -10,7 +10,7 @@ use crate::{ managed_resources::{ManagedResourceRuntimeAdapter, ManagedResourceRuntimeContext}, reconciliation::{ReconciliationQueue, ReconciliationScope}, }; -use anyhow::{Result, bail}; +use anyhow::{Result, anyhow, bail}; use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; @@ -665,9 +665,10 @@ async fn mailpit_reconciliation_records_smtp_and_dashboard_env() -> Result<()> { let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?; drop(mailpit_port_guards); - let result = crate::project_env::reconcile_project_env(&paths, &project.id).await; - print_mailpit_fixture_log_on_error(&paths, &result); - result?; + mailpit_fixture_result( + &paths, + crate::project_env::reconcile_project_env(&paths, &project.id).await, + )?; let snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -723,14 +724,15 @@ async fn mailpit_project_demand_installs_missing_fixture_track_before_start() -> let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?; drop(mailpit_port_guards); - let result = reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url( + mailpit_fixture_result( &paths, - &project.id, - OFFLINE_TEST_MANIFEST_URL, - ) - .await; - print_mailpit_fixture_log_on_error(&paths, &result); - result?; + reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url( + &paths, + &project.id, + OFFLINE_TEST_MANIFEST_URL, + ) + .await, + )?; let snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -1976,7 +1978,14 @@ async fn demanded_resource_cleans_runtime_files_when_process_exits_after_readine drop(mailpit_port_guards); let result = reconcile_project_env_with_fast_exit_fake_runtime_catalog(&paths, &project.id).await; - print_mailpit_fixture_log_on_error(&paths, &result); + if let Err(error) = &result + && !matches!( + error.downcast_ref::(), + Some(DaemonError::UnexpectedProtocolResponse { .. }) + ) + { + return Err(mailpit_fixture_error(&paths, error)); + } let failure_snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -4868,20 +4877,23 @@ fn runtime_files_exist(paths: &PvPaths, track: &str) -> Result(paths: &PvPaths, result: &Result) +fn mailpit_fixture_result(paths: &PvPaths, result: Result) -> Result where E: Debug, { - if let Err(error) = result { - let log_path = paths.resource_log("mailpit", FAKE_MAILPIT_TRACK); - match state::fs::read_to_string(&log_path) { - Ok(log) => eprintln!("Mailpit fixture failed with {error:#?}\n{log}"), - Err(log_error) => { - eprintln!( - "Mailpit fixture failed with {error:#?}; failed to read {log_path}: {log_error}" - ); - } - } + match result { + Ok(value) => Ok(value), + Err(error) => Err(mailpit_fixture_error(paths, &error)), + } +} + +fn mailpit_fixture_error(paths: &PvPaths, error: &impl Debug) -> anyhow::Error { + let log_path = paths.resource_log("mailpit", FAKE_MAILPIT_TRACK); + match state::fs::read_to_string(&log_path) { + Ok(log) => anyhow!("Mailpit fixture failed with {error:#?}\n{log}"), + Err(log_error) => anyhow!( + "Mailpit fixture failed with {error:#?}; failed to read {log_path}: {log_error}" + ), } } From b55cccf953cd11d0632c36334f479d924d53f563 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:22:20 -0400 Subject: [PATCH 08/12] test(daemon): avoid threaded Mailpit HTTP fixtures --- .../daemon/test-fixtures/managed-resources/mailpit-fast-exit.py | 2 +- crates/daemon/test-fixtures/managed-resources/mailpit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py index 7ecdd0ef..5374a4ca 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py @@ -16,5 +16,5 @@ def log_message(self, _format, *_args): pass -server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) +server = http.server.HTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) server.serve_forever() diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py index aba95bbe..5b25d246 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -61,7 +61,7 @@ class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): smtp_server = TcpServer(host_port(smtp), SmtpHandler) -dashboard = http.server.ThreadingHTTPServer( +dashboard = http.server.HTTPServer( host_port(listen), http.server.SimpleHTTPRequestHandler, ) From f9040de2caa5d7c571a66ecdf78e18c6390536ef Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:31:01 -0400 Subject: [PATCH 09/12] test(daemon): trace Mailpit fixture lifecycle --- .../managed-resources/mailpit-fast-exit.py | 5 ++++- .../test-fixtures/managed-resources/mailpit.py | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py index 5374a4ca..e48a9b40 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py @@ -6,6 +6,7 @@ class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): + print("mailpit fast-exit fixture: request received", file=sys.stderr, flush=True) self.send_response(200) self.end_headers() self.wfile.write(b"ready") @@ -16,5 +17,7 @@ def log_message(self, _format, *_args): pass -server = http.server.HTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) +server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) +print("mailpit fast-exit fixture: server constructed", file=sys.stderr, flush=True) +print("mailpit fast-exit fixture: entering serve loop", file=sys.stderr, flush=True) server.serve_forever() diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py index 5b25d246..f259b3f9 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -55,16 +55,24 @@ def handle(self): self.request.sendall(b"220 mailpit fixture\r\n") +class DashboardHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + print("mailpit fixture: dashboard request received", file=sys.stderr, flush=True) + super().do_GET() + + class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True daemon_threads = True smtp_server = TcpServer(host_port(smtp), SmtpHandler) -dashboard = http.server.HTTPServer( +print("mailpit fixture: SMTP server constructed", file=sys.stderr, flush=True) +dashboard = http.server.ThreadingHTTPServer( host_port(listen), - http.server.SimpleHTTPRequestHandler, + DashboardHandler, ) +print("mailpit fixture: dashboard server constructed", file=sys.stderr, flush=True) shutdown_requested = threading.Event() shutdown_thread = None received_signal = None @@ -91,6 +99,7 @@ def stop(signum, _frame): threading.Thread( target=smtp_server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True ).start() +print("mailpit fixture: entering dashboard serve loop", file=sys.stderr, flush=True) dashboard.serve_forever(poll_interval=0.1) if shutdown_thread is not None: shutdown_thread.join() From 66a4c7571ff4ee54d23c4d45bfe5ba92511df0e4 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:41:43 -0400 Subject: [PATCH 10/12] test(daemon): avoid fixture FQDN lookups --- .../fake-frankenphp-hangs-on-port-server.py | 12 +++++++++--- .../gateway/fake-frankenphp-server.py | 14 ++++++++++---- .../managed-resources/fake-mailpit.py | 9 ++++++++- .../managed-resources/mailpit-fast-exit.py | 13 +++++++++---- .../test-fixtures/managed-resources/mailpit.py | 16 +++++++--------- .../managed-resources/rustfs.py.in | 6 ++++++ crates/daemon/tests/fixture_contracts.rs | 17 +++++++++++++++-- 7 files changed, 64 insertions(+), 23 deletions(-) diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py index 04c5deb4..de038ea8 100644 --- a/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 import http.server import signal +import socketserver import sys +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + signal.signal(signal.SIGUSR1, signal.SIG_IGN) port = int(sys.argv[1]) -with http.server.ThreadingHTTPServer( - ("127.0.0.1", port), http.server.SimpleHTTPRequestHandler -) as server: +with Server(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler) as server: server.serve_forever() diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py index c282595d..c1456279 100644 --- a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py @@ -2,6 +2,7 @@ import http.server import re import signal +import socketserver import ssl import sys import threading @@ -32,18 +33,23 @@ def log_message(self, format, *args): pass +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + http_port = int(required(r"^# PV_FAKE_PORT (\d+)$")) https_port = optional(r"^\s*https_port (\d+)$") cert_path = optional(r'^\s*cert "([^"]+)"$') key_path = optional(r'^\s*key "([^"]+)"$') -servers = [http.server.ThreadingHTTPServer(("127.0.0.1", http_port), Handler)] +servers = [Server(("127.0.0.1", http_port), Handler)] if https_port is not None and cert_path is not None and key_path is not None: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(certfile=cert_path, keyfile=key_path) - https_server = http.server.ThreadingHTTPServer( - ("127.0.0.1", int(https_port)), Handler - ) + https_server = Server(("127.0.0.1", int(https_port)), Handler) https_server.socket = context.wrap_socket(https_server.socket, server_side=True) servers.append(https_server) diff --git a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py index 03e6ee0e..499b73ce 100644 --- a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py @@ -16,13 +16,20 @@ def handle(self): self.request.sendall(b"220 fake mailpit\r\n") +class HttpServer(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True daemon_threads = True smtp = TcpServer(("127.0.0.1", int(smtp_port)), SmtpHandler) -dashboard = http.server.ThreadingHTTPServer( +dashboard = HttpServer( ("127.0.0.1", int(dashboard_port)), http.server.SimpleHTTPRequestHandler, ) diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py index e48a9b40..f71e1567 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 import http.server import os +import socketserver import sys class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): - print("mailpit fast-exit fixture: request received", file=sys.stderr, flush=True) self.send_response(200) self.end_headers() self.wfile.write(b"ready") @@ -17,7 +17,12 @@ def log_message(self, _format, *_args): pass -server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) -print("mailpit fast-exit fixture: server constructed", file=sys.stderr, flush=True) -print("mailpit fast-exit fixture: entering serve loop", file=sys.stderr, flush=True) +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + +server = Server(("127.0.0.1", int(sys.argv[2])), Handler) server.serve_forever() diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py index f259b3f9..1a4a29a4 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -55,10 +55,11 @@ def handle(self): self.request.sendall(b"220 mailpit fixture\r\n") -class DashboardHandler(http.server.SimpleHTTPRequestHandler): - def do_GET(self): - print("mailpit fixture: dashboard request received", file=sys.stderr, flush=True) - super().do_GET() +class HttpServer(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): @@ -67,12 +68,10 @@ class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): smtp_server = TcpServer(host_port(smtp), SmtpHandler) -print("mailpit fixture: SMTP server constructed", file=sys.stderr, flush=True) -dashboard = http.server.ThreadingHTTPServer( +dashboard = HttpServer( host_port(listen), - DashboardHandler, + http.server.SimpleHTTPRequestHandler, ) -print("mailpit fixture: dashboard server constructed", file=sys.stderr, flush=True) shutdown_requested = threading.Event() shutdown_thread = None received_signal = None @@ -99,7 +98,6 @@ def stop(signum, _frame): threading.Thread( target=smtp_server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True ).start() -print("mailpit fixture: entering dashboard serve loop", file=sys.stderr, flush=True) dashboard.serve_forever(poll_interval=0.1) if shutdown_thread is not None: shutdown_thread.join() diff --git a/crates/daemon/test-fixtures/managed-resources/rustfs.py.in b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in index 104b7f42..4255e2de 100644 --- a/crates/daemon/test-fixtures/managed-resources/rustfs.py.in +++ b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in @@ -4,6 +4,7 @@ import http.server import os import posixpath import signal +import socketserver import sys import threading import urllib.parse @@ -132,6 +133,11 @@ class ConsoleHandler(http.server.BaseHTTPRequestHandler): class Server(http.server.ThreadingHTTPServer): allow_reuse_address = True + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + api = Server(split_address(api_address), RustfsHandler) console = Server(split_address(console_address), ConsoleHandler) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index d2693844..154f0ce1 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -118,6 +118,15 @@ def serve_forever(self, *args, **kwargs): socketserver.BaseServer.serve_forever = serve_forever "#; +const FAILING_FQDN_SITECUSTOMIZE: &str = r#"import socket + + +def getfqdn(_name=""): + raise RuntimeError("fixture attempted an FQDN lookup") + + +socket.getfqdn = getfqdn +"#; #[expect( clippy::disallowed_types, @@ -428,7 +437,7 @@ fn single_server_fixture_exits_after_signal_status() -> Result<()> { } #[test] -fn multi_server_fixture_exits_after_signal_status() -> Result<()> { +fn multi_server_fixture_avoids_fqdn_lookup_and_exits_after_signal_status() -> Result<()> { for fixture in [ MultiServerFixture::FakeMailpit, MultiServerFixture::Mailpit, @@ -924,15 +933,19 @@ fn assert_multi_server_fixture_exits_after_signal( let second_port = second_port_reservation.local_addr()?.port(); let first_address = format!("127.0.0.1:{first_port}"); let second_address = format!("127.0.0.1:{second_port}"); + let sitecustomize = tempdir.path().join("sitecustomize.py"); let source = match fixture { MultiServerFixture::FakeMailpit => FAKE_MAILPIT_FIXTURE.to_owned(), MultiServerFixture::Mailpit => MAILPIT_FIXTURE.to_owned(), MultiServerFixture::Rustfs => render_rustfs_fixture(false)?, }; + state::fs::write_sensitive_file(&sitecustomize, FAILING_FQDN_SITECUSTOMIZE)?; materialize_fixture(&executable, &source)?; let mut command = FixtureCommand::new(executable.as_std_path()); - command.current_dir(tempdir.path()); + command + .current_dir(tempdir.path()) + .env("PYTHONPATH", tempdir.path()); match fixture { MultiServerFixture::FakeMailpit => { command.args([first_port.to_string(), second_port.to_string()]); From 872e2290c76583d3cbc9e99a848ba9de8dc9f926 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 21:51:57 -0400 Subject: [PATCH 11/12] test(release): avoid RustFS fixture FQDN lookup --- crates/pv-release/tests/smoke.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/pv-release/tests/smoke.rs b/crates/pv-release/tests/smoke.rs index 8e59c50f..1db524e3 100644 --- a/crates/pv-release/tests/smoke.rs +++ b/crates/pv-release/tests/smoke.rs @@ -3772,6 +3772,7 @@ import http.server import os import signal import socket +import socketserver import threading import time @@ -3798,8 +3799,14 @@ class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, _format, *_args): pass +class Server(http.server.HTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + api_host, api_port = split_address(os.environ["PV_TEST_RUSTFS_ADDRESS"]) -server = http.server.HTTPServer((api_host, api_port), Handler) +server = Server((api_host, api_port), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() console_address = os.environ.get("PV_TEST_RUSTFS_CONSOLE_ADDRESS") From 67bfd7889140df090f10732d859c14243e638e60 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Tue, 28 Jul 2026 22:04:57 -0400 Subject: [PATCH 12/12] test(cli): block accepted fake daemon streams --- crates/cli/tests/update.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/tests/update.rs b/crates/cli/tests/update.rs index c995bace..ef82bf0c 100644 --- a/crates/cli/tests/update.rs +++ b/crates/cli/tests/update.rs @@ -1621,6 +1621,7 @@ mod update_tests { while let Some(response) = responses.front() { match listener.accept() { Ok((mut stream, _address)) => { + stream.set_nonblocking(false)?; let mut request = String::new(); BufReader::new(stream.try_clone()?).read_line(&mut request)?; stream.write_all(format!("{response}\n").as_bytes())?;