From 4a5051d4b6f3a55f2b46e22acbd76eb0e339be15 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 5 May 2026 23:46:05 -0300 Subject: [PATCH] feat(runtime): spanker-runtime crate with v0 ioctl wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Rust workspace bootstrap per ADR-001. Lands the spanker-runtime library crate that wraps the v0 ioctl ABI exposed by spanker.ko on /dev/spankerctl. Files: - Cargo.toml — workspace root; resolver "2"; members ["src/runtime"]; shared package metadata (edition 2021, MSRV 1.75, Apache-2.0) and pinned dependencies (nix 0.29 with ioctl feature, thiserror 2.0); release profile with thin-LTO and strip=debuginfo. - rust-toolchain.toml — pins channel 1.75.0 + clippy + rustfmt per ADR-001 line 73. - Cargo.lock — committed (modern Cargo guidance applies to both binary and library workspaces for reproducible CI). - src/runtime/Cargo.toml — spanker-runtime 0.1.0 crate manifest; inherits all metadata from the workspace. - src/runtime/src/lib.rs — public API: SpankerControl handle with open/open_path/ping/version, AbiVersion struct, Error enum, CONTROL_DEVICE_PATH and ABI_VERSION_{MAJOR,MINOR,PATCH} consts. ioctl FFI wrapped in a private `ffi` module with allow(missing_docs) so the nix-generated pub fns don't leak into rustdoc. SAFETY comments on every unsafe block (deny(unsafe_op_in_unsafe_fn) is in force at the crate root). - src/runtime/tests/version_smoke.rs — integration test that opens /dev/spankerctl when present and asserts the kernel-reported ABI matches the runtime-built constants. Skips cleanly when the device is absent so cargo test passes on hosts without spanker.ko loaded. - .github/workflows/ci.yml — new runtime-build job: installs Rust 1.75.0 (MSRV) via dtolnay/rust-toolchain, caches with Swatinem/rust-cache, runs cargo build/test/clippy/fmt, all with --workspace --all-targets and -D warnings. Local verification (Manjaro, rustc 1.94.1): $ cargo build --workspace --all-targets → finished, 0 warnings $ cargo test --workspace --all-targets → 4/4 pass $ cargo clippy --workspace --all-targets --all-features -- -D warnings → clean $ cargo fmt --check --all → clean Notes for the reviewer (Agent R): - The ABI constants and SPANKER_IOC_MAGIC byte are duplicated between this crate and src/driver/include/uapi/spanker_ioctl.h; a future PR will introduce bindgen to derive them from the C header so they cannot drift. Not in scope here — the duplication is two integers and one struct shape. - ADR-001 mentions "edition 2024"; that requires Rust 1.85 which is above the stated MSRV 1.75. Workspace uses edition 2021 to honour the MSRV. ADR-001 deserves an erratum amendment in a follow-up doc PR; not blocking this code PR. - Integration test is currently skip-on-absent; PR #1b (deferred from PR #3) will wire DKMS into CI so the kernel module loads and the smoke test exercises the real ioctl path end-to-end. Authored by Agent 3 (Software Stack). Signed-off-by: Marcos --- .github/workflows/ci.yml | 19 +++ Cargo.lock | 102 ++++++++++++++ Cargo.toml | 29 ++++ rust-toolchain.toml | 10 ++ src/runtime/Cargo.toml | 23 +++ src/runtime/src/lib.rs | 219 +++++++++++++++++++++++++++++ src/runtime/tests/version_smoke.rs | 34 +++++ 7 files changed, 436 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 rust-toolchain.toml create mode 100644 src/runtime/Cargo.toml create mode 100644 src/runtime/src/lib.rs create mode 100644 src/runtime/tests/version_smoke.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8818818..3739506 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,22 @@ jobs: [ -f "$f" ] || continue grep -q "SPDX-License-Identifier" "$f" || { echo "$f missing SPDX header" ; exit 1; } done + + runtime-build: + name: Runtime / cargo + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain (MSRV per ADR-001) + uses: dtolnay/rust-toolchain@1.75.0 + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Build workspace + run: cargo build --workspace --all-targets + - name: Test workspace + run: cargo test --workspace --all-targets + - name: Clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Rustfmt + run: cargo fmt --check --all diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..681906f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,102 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "spanker-runtime" +version = "0.1.0" +dependencies = [ + "nix", + "thiserror", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f364ad7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 PopSolutions Cooperative +# +# Spanker workspace root. +# +# Per ADR-001 — Userspace runtime language (Rust). The workspace +# starts with `spanker-runtime` (this PR); subsequent PRs add +# `spanker-scheduler` (PR #4 distributed scheduler) and +# `ggml-spanker` (PR #3 GGML backend wrapper). + +[workspace] +resolver = "2" +members = ["src/runtime"] + +[workspace.package] +edition = "2021" +license = "Apache-2.0" +authors = ["PopSolutions Cooperative "] +repository = "https://github.com/popsolutions/Spanker" +rust-version = "1.75" + +[workspace.dependencies] +nix = { version = "0.29", default-features = false, features = ["ioctl"] } +thiserror = "2.0" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "debuginfo" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..03a3e70 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 PopSolutions Cooperative +# +# Pins the Rust toolchain for the Spanker workspace. +# Per ADR-001 — Userspace runtime language (Rust, MSRV 1.75.0). + +[toolchain] +channel = "1.75.0" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/src/runtime/Cargo.toml b/src/runtime/Cargo.toml new file mode 100644 index 0000000..c809a38 --- /dev/null +++ b/src/runtime/Cargo.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 PopSolutions Cooperative + +[package] +name = "spanker-runtime" +version = "0.1.0" +description = "Userspace runtime for the PopSolutions Spanker accelerator driver." +keywords = ["accelerator", "ioctl", "popsolutions", "fpga"] +categories = ["hardware-support", "os::linux-apis"] + +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +name = "spanker_runtime" +path = "src/lib.rs" + +[dependencies] +nix = { workspace = true } +thiserror = { workspace = true } diff --git a/src/runtime/src/lib.rs b/src/runtime/src/lib.rs new file mode 100644 index 0000000..8952d21 --- /dev/null +++ b/src/runtime/src/lib.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 PopSolutions Cooperative + +//! # Spanker userspace runtime +//! +//! Per [ADR-001](../../../docs/adr/0001-userspace-runtime-language.md) +//! (Rust userspace) and +//! [ADR-002](../../../docs/adr/0002-driver-model.md) (out-of-tree +//! kbuild kernel module). This crate provides a minimal, blocking +//! handle over the singleton control device `/dev/spankerctl` +//! exposed by `spanker.ko`. +//! +//! ## v0 ABI +//! +//! Mirrors `src/driver/include/uapi/spanker_ioctl.h`. The constants +//! and `ioctl_*` macros below MUST stay in lock-step with that +//! header until ADR-003 introduces a SemVer policy for the ABI. +//! [`SpankerControl::version`] lets callers fail cleanly on a +//! major mismatch. + +#![warn(missing_docs)] +#![deny(unsafe_op_in_unsafe_fn)] + +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; + +/// Default path to the singleton control character device, created +/// by `spanker.ko` at module-load time. +pub const CONTROL_DEVICE_PATH: &str = "/dev/spankerctl"; + +/// Major component of the ABI version this runtime was built +/// against. The kernel driver's reported major MUST equal this; a +/// mismatch is a hard error and the userspace process should +/// refuse to proceed. +pub const ABI_VERSION_MAJOR: u16 = 0; +/// Minor component of the ABI version this runtime was built +/// against. +pub const ABI_VERSION_MINOR: u16 = 1; +/// Patch component of the ABI version this runtime was built +/// against. +pub const ABI_VERSION_PATCH: u16 = 0; + +// FFI shims for the v0 ABI. The `nix::ioctl_*!` macros expand to +// `pub unsafe fn` items that we don't want to surface in the +// crate's public docs, so we wrap them in a private module and +// silence missing_docs locally. +#[allow(missing_docs)] +mod ffi { + /// ioctl magic byte; matches `SPANKER_IOC_MAGIC` in the UAPI + /// header. 0xE3 is a placeholder pending reconciliation with + /// mainline `Documentation/userspace-api/ioctl/ioctl-number.rst` + /// before any in-tree submission attempt (Generation B per + /// ADR-002). + pub(super) const SPANKER_IOC_MAGIC: u8 = 0xE3; + + #[repr(C)] + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub(super) struct SpankerVersionRaw { + pub major: u16, + pub minor: u16, + pub patch: u16, + pub reserved: u16, + } + + nix::ioctl_none!(spanker_ping, SPANKER_IOC_MAGIC, 0x01); + nix::ioctl_read!( + spanker_get_version, + SPANKER_IOC_MAGIC, + 0x02, + SpankerVersionRaw + ); +} + +use ffi::{spanker_get_version, spanker_ping, SpankerVersionRaw}; + +/// ABI version triple reported by the kernel driver via +/// `SPANKER_IOC_GET_VERSION`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct AbiVersion { + /// Major version. Must match [`ABI_VERSION_MAJOR`]. + pub major: u16, + /// Minor version. Newer minors are forward-compatible with this + /// runtime once ADR-003 is accepted. + pub minor: u16, + /// Patch version. Bumps freely. + pub patch: u16, +} + +/// Errors returned by [`SpankerControl`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// `open(2)` on the control device failed (typical cause: + /// `spanker.ko` is not loaded, or the caller lacks permission). + #[error("open {path}: {source}")] + Open { + /// Path that failed to open. + path: PathBuf, + /// Underlying I/O error from libc. + #[source] + source: io::Error, + }, + + /// An ioctl call returned a non-zero status. + #[error("ioctl SPANKER_IOC_{op} failed: {source}")] + Ioctl { + /// Symbolic opcode name. + op: &'static str, + /// Underlying nix error. + #[source] + source: nix::Error, + }, +} + +/// Convenience alias for results returned by this crate. +pub type Result = std::result::Result; + +/// Handle to the Spanker singleton control device. +/// +/// The device is created by `spanker.ko` at module-load time and +/// always exists once the kernel module is loaded, regardless of +/// whether any Sails are physically attached. +pub struct SpankerControl { + file: File, +} + +impl SpankerControl { + /// Open the default control device at [`CONTROL_DEVICE_PATH`]. + pub fn open() -> Result { + Self::open_path(CONTROL_DEVICE_PATH) + } + + /// Open a control device at an arbitrary path. Used by tests + /// and by callers that need to talk to a non-default path + /// (e.g., a chroot or a developer mock). + pub fn open_path>(path: P) -> Result { + let p = path.as_ref(); + let file = OpenOptions::new() + .read(true) + .write(true) + .open(p) + .map_err(|source| Error::Open { + path: p.to_path_buf(), + source, + })?; + Ok(Self { file }) + } + + /// Smoke-test the ioctl dispatcher (`SPANKER_IOC_PING`). The + /// kernel driver returns 0 unconditionally; a non-zero result + /// means the dispatcher is broken or the magic byte mismatches. + pub fn ping(&self) -> Result<()> { + // SAFETY: `spanker_ping` is a no-payload ioctl that takes + // only the file descriptor; the kernel does no reads or + // writes through any user pointer. + unsafe { spanker_ping(self.file.as_raw_fd()) } + .map(|_| ()) + .map_err(|source| Error::Ioctl { op: "PING", source }) + } + + /// Read the kernel driver's reported ABI version + /// (`SPANKER_IOC_GET_VERSION`). + pub fn version(&self) -> Result { + let mut raw = SpankerVersionRaw::default(); + // SAFETY: `&mut raw` points to an owned local stack value; + // the kernel copies exactly `sizeof(SpankerVersionRaw)` (8) + // bytes into it via copy_to_user. No aliasing, no escape. + unsafe { spanker_get_version(self.file.as_raw_fd(), &mut raw) }.map_err(|source| { + Error::Ioctl { + op: "GET_VERSION", + source, + } + })?; + Ok(AbiVersion { + major: raw.major, + minor: raw.minor, + patch: raw.patch, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_path_reports_missing_device() { + let bogus = "/tmp/spanker-runtime-test-does-not-exist"; + let err = SpankerControl::open_path(bogus) + .err() + .expect("expected failure on bogus path"); + match err { + Error::Open { path, source } => { + assert_eq!(path, PathBuf::from(bogus)); + assert_eq!(source.kind(), io::ErrorKind::NotFound); + } + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn raw_version_struct_is_eight_bytes() { + // The UAPI struct is wire-stable at 8 bytes; if this + // assertion fires, the kernel-side struct changed shape + // and userspace bindings are now lying to callers. + assert_eq!(std::mem::size_of::(), 8); + } + + #[test] + fn abi_constants_match_built_values() { + // Sanity: the public consts match the values baked into + // the ioctl version response. Catches accidental skew if + // someone bumps the consts but forgets the kernel side. + assert_eq!(ABI_VERSION_MAJOR, 0); + assert_eq!(ABI_VERSION_MINOR, 1); + assert_eq!(ABI_VERSION_PATCH, 0); + } +} diff --git a/src/runtime/tests/version_smoke.rs b/src/runtime/tests/version_smoke.rs new file mode 100644 index 0000000..c0bb778 --- /dev/null +++ b/src/runtime/tests/version_smoke.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 PopSolutions Cooperative + +//! Integration smoke test for the runtime library. +//! +//! Only meaningful when `/dev/spankerctl` is present (i.e. when the +//! `spanker.ko` kernel module is loaded). Skips otherwise so that +//! `cargo test --workspace` passes on any developer host — CI does +//! not currently insmod the kernel module (deferred to a follow-up +//! that wires DKMS into the runner). + +use spanker_runtime::{SpankerControl, ABI_VERSION_MAJOR, ABI_VERSION_MINOR, CONTROL_DEVICE_PATH}; + +#[test] +fn version_matches_built_constants_when_device_present() { + if !std::path::Path::new(CONTROL_DEVICE_PATH).exists() { + eprintln!("skipping: {CONTROL_DEVICE_PATH} not present (load spanker.ko first)"); + return; + } + + let ctl = match SpankerControl::open() { + Ok(c) => c, + Err(e) => { + eprintln!("skipping: cannot open {CONTROL_DEVICE_PATH}: {e}"); + return; + } + }; + + let v = ctl.version().expect("GET_VERSION ioctl failed"); + assert_eq!(v.major, ABI_VERSION_MAJOR, "ABI major mismatch"); + assert_eq!(v.minor, ABI_VERSION_MINOR, "ABI minor mismatch"); + + ctl.ping().expect("PING ioctl failed"); +}