Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 |
@@ -145,9 +145,18 @@ jobs:
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
|
||||
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
|
||||
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
|
||||
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
|
||||
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
|
||||
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
|
||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
|
||||
@@ -45,12 +45,20 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
|
||||
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
|
||||
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
||||
/// are droppable for the same reason a stale ICMP is.
|
||||
/// - Windows `WSAENOBUFS` (10055): the exact analogue of unix `ENOBUFS` — a high-bitrate keyframe
|
||||
/// burst (one `WSASendMsg` USO super-buffer is up to ~512 segments ≈ 700 KB) momentarily exhausts
|
||||
/// the socket send buffer / AFD non-paged pool, and Winsock reports `WSAENOBUFS`, which Rust maps
|
||||
/// to `ErrorKind::Uncategorized` (so the `WouldBlock` arm misses it, exactly like unix `ENOBUFS`).
|
||||
/// Without treating it as transient a Windows host tears the whole session down under load
|
||||
/// (observed live: `native::stream` "send failed — stopping stream" on a paced video burst). Same
|
||||
/// lossy-drop contract; FEC + the next frame recover. The `WSAENET*`/`WSAEHOST*` family is the
|
||||
/// Windows counterpart of the droppable unix network-path blips above.
|
||||
fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
||||
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
||||
return true;
|
||||
}
|
||||
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only).
|
||||
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
matches!(
|
||||
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
| Some(libc::EHOSTDOWN)
|
||||
)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
// Windows Winsock codes (WSAE*), raw like the sibling `uso_unsupported`. WSAEWOULDBLOCK (10035)
|
||||
// already maps to `ErrorKind::WouldBlock` above, so it isn't repeated here.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
matches!(
|
||||
e.raw_os_error(),
|
||||
Some(10055) // WSAENOBUFS — tx queue / send buffer full (the dominant high-bitrate drop)
|
||||
| Some(10051) // WSAENETUNREACH
|
||||
| Some(10065) // WSAEHOSTUNREACH
|
||||
| Some(10050) // WSAENETDOWN
|
||||
| Some(10064) // WSAEHOSTDOWN
|
||||
)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
false
|
||||
}
|
||||
@@ -324,6 +345,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw-errno tx-queue-full / network-blip codes have no stable `ErrorKind` (they surface as
|
||||
/// `Uncategorized`), so they only get caught by the platform `raw_os_error()` arms. A burst that
|
||||
/// momentarily exhausts the send buffer must stay a lossy drop, never a teardown — this is the
|
||||
/// regression guard for the Windows `WSAENOBUFS` (10055) session crash and the unix `ENOBUFS`
|
||||
/// wlan-driver case. Gated per platform because a code is only classified on its own OS.
|
||||
#[test]
|
||||
fn transient_io_covers_raw_tx_queue_and_path_codes() {
|
||||
use std::io::Error;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
for code in [
|
||||
libc::ENOBUFS,
|
||||
libc::ENETUNREACH,
|
||||
libc::EHOSTUNREACH,
|
||||
libc::ENETDOWN,
|
||||
libc::EHOSTDOWN,
|
||||
] {
|
||||
assert!(
|
||||
is_transient_io(&Error::from_raw_os_error(code)),
|
||||
"unix errno {code} should be transient"
|
||||
);
|
||||
}
|
||||
// A genuine failure with no stable ErrorKind must still tear down.
|
||||
assert!(
|
||||
!is_transient_io(&Error::from_raw_os_error(libc::EACCES)),
|
||||
"EACCES must stay fatal"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// WSAENOBUFS / WSAENETUNREACH / WSAEHOSTUNREACH / WSAENETDOWN / WSAEHOSTDOWN.
|
||||
for code in [10055, 10051, 10065, 10050, 10064] {
|
||||
assert!(
|
||||
is_transient_io(&Error::from_raw_os_error(code)),
|
||||
"WSA code {code} should be transient"
|
||||
);
|
||||
}
|
||||
// WSAEACCES (10013) — a real failure that must stay fatal.
|
||||
assert!(
|
||||
!is_transient_io(&Error::from_raw_os_error(10013)),
|
||||
"WSAEACCES must stay fatal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
|
||||
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
|
||||
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use pf_paths::config_dir;
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::pkcs8::DecodePrivateKey;
|
||||
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||
use rsa::RsaPrivateKey;
|
||||
use sha2::Sha256;
|
||||
use std::fs;
|
||||
@@ -70,7 +70,20 @@ impl ServerIdentity {
|
||||
}
|
||||
|
||||
fn generate() -> Result<(String, String)> {
|
||||
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?;
|
||||
// The workspace is ring-only (aws-lc-sys breaks Windows CI — see the rustls/rcgen pins), and
|
||||
// `ring` can *sign* with an existing RSA key but cannot *generate* one: rcgen's ring backend
|
||||
// returns `KeyGenerationUnavailable` for `generate_for(&PKCS_RSA_SHA256)`. Moonlight requires an
|
||||
// RSA-2048 identity, so generate the key with the pure-Rust `rsa` crate (already a dep for the
|
||||
// pairing signer) and hand the PKCS#8 PEM to rcgen, whose ring backend *can* load + self-sign
|
||||
// with it. Returning that same PEM keeps it byte-identical to what `from_pems` re-parses.
|
||||
let mut rng = rand::thread_rng();
|
||||
let priv_key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 host key")?;
|
||||
let key_pem = priv_key
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.context("encode host key as PKCS#8 PEM")?
|
||||
.to_string();
|
||||
let key = rcgen::KeyPair::from_pkcs8_pem_and_sign_algo(&key_pem, &rcgen::PKCS_RSA_SHA256)
|
||||
.context("load RSA host key into rcgen")?;
|
||||
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
|
||||
params
|
||||
.distinguished_name
|
||||
@@ -78,7 +91,7 @@ fn generate() -> Result<(String, String)> {
|
||||
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
||||
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
||||
let cert = params.self_signed(&key).context("self-sign cert")?;
|
||||
Ok((cert.pem(), key.serialize_pem()))
|
||||
Ok((cert.pem(), key_pem))
|
||||
}
|
||||
|
||||
/// Extract the X.509 `signatureValue` bytes from a cert PEM.
|
||||
|
||||
@@ -176,16 +176,48 @@ impl HooksConfig {
|
||||
|
||||
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
|
||||
/// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds.
|
||||
///
|
||||
/// A hand-edited `hooks.json` is honored WITHOUT a restart (the documented contract): [`get`]
|
||||
/// re-stats the file and reloads when its identity (mtime + length) moved. The stat rides the
|
||||
/// per-event dispatch, so the check costs one `metadata()` call per event, and a full re-read
|
||||
/// happens only when the file actually changed.
|
||||
///
|
||||
/// [`get`]: HooksStore::get
|
||||
pub struct HooksStore {
|
||||
path: PathBuf,
|
||||
cur: Mutex<Option<HooksConfig>>,
|
||||
cur: Mutex<StoreState>,
|
||||
}
|
||||
|
||||
struct StoreState {
|
||||
cfg: Option<HooksConfig>,
|
||||
/// Identity of the file revision `cfg` was parsed from (mtime + length); `None` = the file
|
||||
/// did not exist. `get` compares against a fresh stat to detect hand edits.
|
||||
file_id: Option<(std::time::SystemTime, u64)>,
|
||||
}
|
||||
|
||||
impl HooksStore {
|
||||
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
|
||||
/// (never fail host startup over a settings file).
|
||||
pub fn load_from(path: PathBuf) -> Self {
|
||||
let cur = match std::fs::read(&path) {
|
||||
let (cfg, file_id) = Self::read_disk(&path);
|
||||
HooksStore {
|
||||
path,
|
||||
cur: Mutex::new(StoreState { cfg, file_id }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's on-disk identity, `None` when it does not exist (or cannot be stat'd —
|
||||
/// indistinguishable on purpose: both mean "no usable hooks file").
|
||||
fn file_identity(path: &PathBuf) -> Option<(std::time::SystemTime, u64)> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
Some((meta.modified().ok()?, meta.len()))
|
||||
}
|
||||
|
||||
/// Read + validate the file. Same lenient contract as startup: missing ⇒ no hooks;
|
||||
/// invalid/unreadable ⇒ no hooks with a warning naming the problem.
|
||||
fn read_disk(path: &PathBuf) -> (Option<HooksConfig>, Option<(std::time::SystemTime, u64)>) {
|
||||
let file_id = Self::file_identity(path);
|
||||
let cfg = match std::fs::read(path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
|
||||
Ok(c) => {
|
||||
if let Err(e) = c.validate() {
|
||||
@@ -204,15 +236,23 @@ impl HooksStore {
|
||||
},
|
||||
Err(_) => None,
|
||||
};
|
||||
HooksStore {
|
||||
path,
|
||||
cur: Mutex::new(cur),
|
||||
}
|
||||
(cfg, file_id)
|
||||
}
|
||||
|
||||
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
|
||||
/// Re-reads `hooks.json` first if it changed on disk since last load, so hand edits apply
|
||||
/// on the next event, no restart ("changes apply immediately" — docs/automation.md).
|
||||
pub fn get(&self) -> HooksConfig {
|
||||
self.cur.lock().unwrap().clone().unwrap_or_default()
|
||||
let mut st = self.cur.lock().unwrap();
|
||||
let now_id = Self::file_identity(&self.path);
|
||||
if now_id != st.file_id {
|
||||
let (cfg, file_id) = Self::read_disk(&self.path);
|
||||
tracing::info!(path = %self.path.display(), hooks = cfg.as_ref().map_or(0, |c| c.hooks.len()),
|
||||
"hooks.json changed on disk — reloaded");
|
||||
st.cfg = cfg;
|
||||
st.file_id = file_id;
|
||||
}
|
||||
st.cfg.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist + adopt a new configuration (caller validates first). The in-memory value
|
||||
@@ -224,12 +264,15 @@ impl HooksStore {
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
*self.cur.lock().unwrap() = Some(cfg);
|
||||
let mut st = self.cur.lock().unwrap();
|
||||
st.file_id = Self::file_identity(&self.path);
|
||||
st.cfg = Some(cfg);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access.
|
||||
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded on first access and
|
||||
/// re-loaded whenever the file changes on disk (see [`HooksStore::get`]).
|
||||
pub fn store() -> &'static HooksStore {
|
||||
static STORE: OnceLock<HooksStore> = OnceLock::new();
|
||||
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
|
||||
@@ -860,6 +903,42 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_edited_file_reloads_without_restart() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pf-hooks-reload-test-{}-{:p}.json",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let store = HooksStore::load_from(path.clone());
|
||||
assert!(store.get().hooks.is_empty());
|
||||
|
||||
// The documented flow: the operator writes hooks.json by hand and the SAME running
|
||||
// store honors it on the next event — no restart, no PUT.
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"hooks":[{"on":"stream.started","run":"true"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.get().hooks.len(), 1, "hand edit applies on next read");
|
||||
assert_eq!(store.get().hooks[0].on, "stream.started");
|
||||
|
||||
// A second edit applies too (length differs, so same-second mtime granularity can't
|
||||
// mask it).
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"hooks":[{"on":"stream.started","run":"true"},{"on":"client.*","run":"true"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.get().hooks.len(), 2, "second hand edit applies too");
|
||||
|
||||
// Deleting the file removes the hooks.
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(store.get().hooks.is_empty(), "deleted file = no hooks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_constrain_and_missing_fields_never_match() {
|
||||
let ev = sample_event();
|
||||
|
||||
Reference in New Issue
Block a user