diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..5d33f42 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - wsl2 + - wslg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 547beca..591a367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,13 @@ on: description: 'Git ref to verify; defaults to the triggering ref' required: false type: string + workflow_dispatch: + inputs: + run_wslg_receipt: + description: 'Run the authoritative in-distro WSL2/WSLg receipt on the labeled self-hosted runner' + required: true + default: false + type: boolean # Cancel superseded runs on the same ref to save runner minutes. concurrency: @@ -121,5 +128,47 @@ jobs: - name: Run platform default-shell and authority integration run: >- - cargo test -p splice-pty --test session_contract && - cargo test -p splice-shell-desktop --test platform_authority + cargo test -p splice-pty --test session_contract && + cargo test -p splice-shell-desktop --test platform_authority + + wslg-runtime-receipt: + name: WSL2/WSLg runtime receipt (manual, authoritative) + # Generic Linux and hosted Windows do not prove this model. Successful real WSL2/WSLg receipt is required before support may be declared. + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_wslg_receipt }} + runs-on: [self-hosted, linux, x64, wsl2, wslg] + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Verify in-distro WSL2 and WSLg prerequisites + run: >- + grep -q '^ID=ubuntu$' /etc/os-release && + grep -Eq '^VERSION_ID="(22.04|24.04)"$' /etc/os-release && + test -n "$WSL_DISTRO_NAME" && + grep -qi wsl2 /proc/sys/kernel/osrelease && + test -n "$WAYLAND_DISPLAY" && + test -n "$XDG_RUNTIME_DIR" && + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" && + test -x /bin/sh && command -v xdg-open + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + + - name: Run platform authority/default-shell prerequisite + run: cargo test -p splice-shell-desktop --test platform_authority + + - name: Run WSL2/WSLg PTY receipt harness + env: + SPLICE_WSL_RECEIPT: artifacts/wsl-runtime-receipt.json + SPLICE_WSL_RECEIPT_REQUIRED: 'true' + run: cargo test -p splice-pty --test wsl_runtime_receipt -- --nocapture + + - name: Upload WSL2/WSLg machine-readable receipt + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wsl2-wslg-runtime-receipt + path: artifacts/wsl-runtime-receipt.json + if-no-files-found: error diff --git a/crates/splice-pty/tests/wsl_runtime_receipt.rs b/crates/splice-pty/tests/wsl_runtime_receipt.rs new file mode 100644 index 0000000..a375b3b --- /dev/null +++ b/crates/splice-pty/tests/wsl_runtime_receipt.rs @@ -0,0 +1,91 @@ +mod wsl_runtime_support; + +use std::{fs, path::PathBuf}; + +use wsl_runtime_support::{ + classify, require_authoritative_receipt, run_receipt, write_receipt, WslFacts, WslRuntime, +}; + +#[test] +fn deterministic_fixtures_distinguish_ready_wslg_from_named_skips() { + let ready = classify(WslFacts { + os_release: "ID=ubuntu\nVERSION_ID=\"24.04\"\n", + kernel_release: "6.8.0-microsoft-standard-WSL2", + distro_name: Some("Ubuntu"), + wayland_display: Some("wayland-0"), + wayland_socket: true, + }); + assert_eq!(ready, WslRuntime::Ready); + + let generic_linux = classify(WslFacts { + kernel_release: "6.8.0-generic", + ..WslFacts::supported() + }); + assert_eq!( + generic_linux, + WslRuntime::Skip("not a WSL2 kernel; generic Linux must not claim a WSL receipt") + ); + + let missing_wslg = classify(WslFacts { + wayland_display: None, + wayland_socket: false, + ..WslFacts::supported() + }); + assert_eq!( + missing_wslg, + WslRuntime::Skip("WSLg Wayland display is unavailable") + ); +} + +#[test] +fn required_mode_rejects_a_skipped_receipt() { + let skipped = WslRuntime::Skip("deterministic unsupported fixture"); + assert!(require_authoritative_receipt(skipped, false).is_ok()); + assert_eq!( + require_authoritative_receipt(skipped, true), + Err("deterministic unsupported fixture") + ); +} + +#[test] +fn guarded_wsl_receipt_workflow_requires_a_real_self_hosted_wslg_host() { + let workflow = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.github/workflows/ci.yml"); + let workflow = fs::read_to_string(workflow).expect("CI workflow is readable"); + + for required_contract in [ + "run_wslg_receipt", + "WSL2/WSLg runtime receipt (manual, authoritative)", + "runs-on: [self-hosted, linux, x64, wsl2, wslg]", + "SPLICE_WSL_RECEIPT: artifacts/wsl-runtime-receipt.json", + "SPLICE_WSL_RECEIPT_REQUIRED: 'true'", + "grep -q '^ID=ubuntu$' /etc/os-release", + "grep -Eq '^VERSION_ID=\"(22.04|24.04)\"$' /etc/os-release", + "cargo test -p splice-pty --test wsl_runtime_receipt -- --nocapture", + "cargo test -p splice-shell-desktop --test platform_authority", + "actions/upload-artifact", + "Successful real WSL2/WSLg receipt is required before support may be declared.", + ] { + assert!( + workflow.contains(required_contract), + "WSL receipt workflow must declare: {required_contract}" + ); + } + + let actionlint = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.github/actionlint.yaml"); + let actionlint = fs::read_to_string(actionlint).expect("actionlint custom-runner metadata"); + assert!(actionlint.contains("- wsl2") && actionlint.contains("- wslg")); +} + +#[test] +fn runtime_harness_emits_a_named_skip_or_a_real_wslg_receipt() { + let status = run_receipt(); + write_receipt(status); + match status { + WslRuntime::Ready => println!("RECEIPT: WSL2/WSLg PTY runtime passed"), + WslRuntime::Skip(reason) => println!("SKIP: WSL2/WSLg PTY runtime: {reason}"), + } + let required = std::env::var("SPLICE_WSL_RECEIPT_REQUIRED").is_ok_and(|value| value == "true"); + require_authoritative_receipt(status, required) + .expect("authoritative WSL2/WSLg receipt must not skip"); +} diff --git a/crates/splice-pty/tests/wsl_runtime_support.rs b/crates/splice-pty/tests/wsl_runtime_support.rs new file mode 100644 index 0000000..7d119dd --- /dev/null +++ b/crates/splice-pty/tests/wsl_runtime_support.rs @@ -0,0 +1,213 @@ +use std::{ + fs, + path::Path, + sync::mpsc, + time::{Duration, Instant}, +}; + +use splice_pty::{PtySession, TerminalSize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WslRuntime { + Ready, + Skip(&'static str), +} + +pub struct WslFacts<'a> { + pub os_release: &'a str, + pub kernel_release: &'a str, + pub distro_name: Option<&'a str>, + pub wayland_display: Option<&'a str>, + pub wayland_socket: bool, +} + +impl<'a> WslFacts<'a> { + pub fn supported() -> Self { + Self { + os_release: "ID=ubuntu\nVERSION_ID=\"24.04\"\n", + kernel_release: "6.8.0-microsoft-standard-WSL2", + distro_name: Some("Ubuntu"), + wayland_display: Some("wayland-0"), + wayland_socket: true, + } + } +} + +pub fn classify(facts: WslFacts<'_>) -> WslRuntime { + if !facts.kernel_release.to_ascii_lowercase().contains("wsl2") { + return WslRuntime::Skip("not a WSL2 kernel; generic Linux must not claim a WSL receipt"); + } + if facts.distro_name.is_none_or(str::is_empty) { + return WslRuntime::Skip("WSL_DISTRO_NAME is required for an in-distro WSL receipt"); + } + if !facts.os_release.lines().any(|line| line == "ID=ubuntu") + || !facts + .os_release + .lines() + .any(|line| matches!(line, "VERSION_ID=\"22.04\"" | "VERSION_ID=\"24.04\"")) + { + return WslRuntime::Skip("WSL receipt requires Ubuntu 22.04 or 24.04"); + } + if facts.wayland_display.is_none_or(str::is_empty) || !facts.wayland_socket { + return WslRuntime::Skip("WSLg Wayland display is unavailable"); + } + WslRuntime::Ready +} + +pub fn require_authoritative_receipt( + status: WslRuntime, + required: bool, +) -> Result<(), &'static str> { + match (required, status) { + (true, WslRuntime::Skip(reason)) => Err(reason), + _ => Ok(()), + } +} + +fn receive_until(receiver: &mpsc::Receiver, marker: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(5); + let mut output = String::new(); + while Instant::now() < deadline && !output.contains(marker) { + if let Ok(chunk) = receiver.recv_timeout(Duration::from_millis(250)) { + output.push_str(&chunk); + } + } + output +} + +fn is_alive(pid: &str) -> bool { + std::process::Command::new("kill") + .args(["-0", pid]) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(unix)] +fn is_wayland_socket(path: &Path) -> bool { + use std::os::unix::fs::FileTypeExt; + + fs::metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket()) +} + +#[cfg(not(unix))] +fn is_wayland_socket(_: &Path) -> bool { + false +} + +fn skip_or_ready() -> WslRuntime { + let kernel_release = fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default(); + let os_release = fs::read_to_string("/etc/os-release").unwrap_or_default(); + let display = std::env::var("WAYLAND_DISPLAY").ok(); + let socket = std::env::var("XDG_RUNTIME_DIR") + .ok() + .zip(display.as_deref()) + .is_some_and(|(runtime_dir, display)| { + is_wayland_socket(&Path::new(&runtime_dir).join(display)) + }); + classify(WslFacts { + os_release: &os_release, + kernel_release: &kernel_release, + distro_name: std::env::var("WSL_DISTRO_NAME").ok().as_deref(), + wayland_display: display.as_deref(), + wayland_socket: socket, + }) +} + +pub fn run_receipt() -> WslRuntime { + let status = skip_or_ready(); + if status != WslRuntime::Ready { + return status; + } + if !Path::new("/bin/sh").is_file() { + return WslRuntime::Skip("WSL default shell prerequisite /bin/sh is unavailable"); + } + if std::env::var_os("PATH").is_none() { + return WslRuntime::Skip("PATH is required for WSL platform authority and path reveal"); + } + if !matches!(std::process::Command::new("sh") + .args(["-c", "command -v xdg-open"]) + .status(), Ok(status) if status.success()) + { + return WslRuntime::Skip("xdg-open is required for WSL path reveal"); + } + + let size = TerminalSize::new(80, 24).expect("fixture terminal size"); + let (sender, receiver) = mpsc::channel(); + let session = PtySession::spawn( + "/bin/sh", + &["-c", "printf 'ready utf8=café\\n'; stty size; read value; printf 'input=%s\\n' \"$value\"; stty size"], + size, + move |_, output| { let _ = sender.send(output); }, + |_| {}, + ).expect("WSL default shell starts"); + assert!(session.is_running().expect("liveness is available")); + assert!(receive_until(&receiver, "24 80").contains("utf8=café")); + session + .resize(TerminalSize::new(132, 43).expect("resize size")) + .expect("resize succeeds"); + session.write("wsl-input\n").expect("input succeeds"); + assert!(receive_until(&receiver, "43 132").contains("input=wsl-input")); + session.close(); + assert!(!session.is_running().expect("liveness after close")); + + let (sender, receiver) = mpsc::channel(); + let session = PtySession::spawn( + "/bin/sh", + &[ + "-c", + "trap 'printf interrupted; exit' INT; printf ready; while :; do sleep 1; done", + ], + size, + move |_, output| { + let _ = sender.send(output); + }, + |_| {}, + ) + .expect("WSL interrupt shell starts"); + assert!(receive_until(&receiver, "ready").contains("ready")); + session.interrupt().expect("interrupt succeeds"); + assert!(receive_until(&receiver, "interrupted").contains("interrupted")); + session.close(); + + let (sender, receiver) = mpsc::channel(); + let session = PtySession::spawn( + "/bin/sh", + &["-c", "trap '' HUP TERM; sh -c 'trap \"\" HUP TERM; echo child=$$; while :; do sleep 1; done' & wait"], + size, + move |_, output| { let _ = sender.send(output); }, + |_| {}, + ).expect("WSL teardown shell starts"); + let output = receive_until(&receiver, "child="); + let child = output + .split("child=") + .nth(1) + .expect("child pid") + .lines() + .next() + .expect("child pid line") + .trim(); + session.close(); + session.close(); + assert!( + !is_alive(child), + "close must tear down the WSL process group" + ); + WslRuntime::Ready +} + +pub fn write_receipt(status: WslRuntime) { + let (outcome, reason) = match status { + WslRuntime::Ready => ("passed", "all WSL2/WSLg PTY probes passed"), + WslRuntime::Skip(reason) => ("skipped", reason), + }; + let receipt = format!( + "{{\"target\":\"linux-native-wsl2-wslg\",\"status\":\"{outcome}\",\"reason\":\"{reason}\",\"probes\":[\"startup\",\"utf8\",\"input\",\"resize\",\"interrupt\",\"process_group_teardown\",\"liveness\",\"close\"],\"platform_prerequisites\":[\"platform_authority\",\"default_shell\",\"PATH\",\"xdg-open\"]}}" + ); + if let Some(path) = std::env::var_os("SPLICE_WSL_RECEIPT") { + let path = Path::new(&path); + fs::create_dir_all(path.parent().expect("receipt has a parent")) + .expect("receipt directory"); + fs::write(path, &receipt).expect("machine-readable WSL receipt"); + } + println!("WSL_RECEIPT: {receipt}"); +}