Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 |
@@ -145,9 +145,18 @@ jobs:
|
|||||||
- name: Clippy (host + tray, Windows)
|
- name: Clippy (host + tray, Windows)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
# 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: |
|
run: |
|
||||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
cargo clippy --release -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-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
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
|
/// 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`)
|
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
||||||
/// are droppable for the same reason a stale ICMP is.
|
/// 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 {
|
fn is_transient_io(e: &std::io::Error) -> bool {
|
||||||
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
||||||
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
||||||
return true;
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
matches!(
|
matches!(
|
||||||
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
|||||||
| Some(libc::EHOSTDOWN)
|
| 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
|
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
|
/// `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 `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.
|
/// 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 anyhow::{anyhow, Context, Result};
|
||||||
use pf_paths::config_dir;
|
use pf_paths::config_dir;
|
||||||
use rsa::pkcs1v15::SigningKey;
|
use rsa::pkcs1v15::SigningKey;
|
||||||
use rsa::pkcs8::DecodePrivateKey;
|
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||||
use rsa::RsaPrivateKey;
|
use rsa::RsaPrivateKey;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -70,7 +70,20 @@ impl ServerIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn generate() -> Result<(String, String)> {
|
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")?;
|
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
|
||||||
params
|
params
|
||||||
.distinguished_name
|
.distinguished_name
|
||||||
@@ -78,7 +91,7 @@ fn generate() -> Result<(String, String)> {
|
|||||||
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
||||||
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
||||||
let cert = params.self_signed(&key).context("self-sign cert")?;
|
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.
|
/// 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:
|
/// 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.
|
/// 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 {
|
pub struct HooksStore {
|
||||||
path: PathBuf,
|
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 {
|
impl HooksStore {
|
||||||
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
|
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
|
||||||
/// (never fail host startup over a settings file).
|
/// (never fail host startup over a settings file).
|
||||||
pub fn load_from(path: PathBuf) -> Self {
|
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(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
if let Err(e) = c.validate() {
|
if let Err(e) = c.validate() {
|
||||||
@@ -204,15 +236,23 @@ impl HooksStore {
|
|||||||
},
|
},
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
HooksStore {
|
(cfg, file_id)
|
||||||
path,
|
|
||||||
cur: Mutex::new(cur),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
|
/// 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 {
|
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
|
/// 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");
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
|
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
|
||||||
std::fs::rename(&tmp, &self.path)?;
|
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(())
|
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 {
|
pub fn store() -> &'static HooksStore {
|
||||||
static STORE: OnceLock<HooksStore> = OnceLock::new();
|
static STORE: OnceLock<HooksStore> = OnceLock::new();
|
||||||
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
|
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);
|
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]
|
#[test]
|
||||||
fn filters_constrain_and_missing_fields_never_match() {
|
fn filters_constrain_and_missing_fields_never_match() {
|
||||||
let ev = sample_event();
|
let ev = sample_event();
|
||||||
|
|||||||
Reference in New Issue
Block a user