Compare commits

...

4 Commits

Author SHA1 Message Date
enricobuehler d579cd318e ci(windows-host): lint host + tray in --release so clippy reuses the release build
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m13s
docker / deploy-docs (push) Successful in 12s
ci / rust (push) Successful in 19m29s
deb / build-publish (push) Successful in 11m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m54s
apple / swift (push) Successful in 1m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m34s
windows-host / package (push) Successful in 16m22s
apple / screenshots (push) Successful in 6m18s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m38s
android / android (push) Successful in 13m16s
ci / bench (push) Successful in 6m0s
arch / build-publish (push) Successful in 11m57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
The clippy step ran in the default (debug) profile, compiling the whole
dep tree into a second target dir (C:\t\debug) — a second full build of
openh264-sys2's vendored C++ (pf-encode's software-H.264 fallback) on top
of the release copy the Build steps already produced. That second cc-rs
cl.exe fan-out tips the self-hosted runner into
`cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)`
— environmental disk/temp exhaustion, not a source error: the identical
file compiles fine in the release build minutes earlier, and openh264 is
untouched here. Linting in --release reuses the release build-script
artifacts (no openh264 rebuild) and keeps everything in one C:\t\release
tree. Matches pf-vkhdr-layer's clippy, which already runs --release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 02:12:43 +02:00
enricobuehler bb755ef7d2 fix(gamestream): generate the RSA host identity via the rsa crate, not rcgen (ring can't RSA-keygen)
apple / screenshots (push) Successful in 6m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m54s
android / android (push) Successful in 13m24s
windows-host / package (push) Successful in 18m37s
deb / build-publish (push) Successful in 12m42s
arch / build-publish (push) Successful in 16m12s
ci / rust (push) Successful in 27m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m29s
docker / deploy-docs (push) Successful in 27s
ci / web (push) Successful in 1m22s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m29s
decky / build-publish (push) Successful in 26s
ci / bench (push) Successful in 6m16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
The workspace went ring-only in 0.13.0 (aws-lc-sys breaks Windows CI), 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)`. So `ServerIdentity::{load_or_create,
ephemeral}` panics with "rcgen RSA keygen / There is no support for
generating keys for the given algorithm" on any host without an existing
`cert.pem`. This is the shared trust root for both the GameStream TLS cert
and the QUIC identity clients pin, so a *fresh* 0.13.0 install can't start
the host at all (existing dev boxes survive only because they already have
a cert on disk); in CI every test that builds a `ServerIdentity` (all of
`mgmt::tests`, `gamestream::nvhttp`, and the `native::tests` synthetic host
via `serve()`) failed — 30 failures from this one cause.

Moonlight requires an RSA-2048 identity, so generate the key with the
pure-Rust `rsa` crate (already a dependency for the pairing signer) and
hand its PKCS#8 PEM to rcgen, whose ring backend *can* load and self-sign
with an externally supplied RSA key (`from_pkcs8_pem_and_sign_algo` →
`RsaKeyPair::from_pkcs8`). Return that same PEM so it stays byte-identical
to what `from_pems` re-parses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:59:28 +02:00
enricobuehler 08a397cf08 fix(transport): treat Windows WSAENOBUFS as a transient send drop, not a stream teardown
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m17s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 7m7s
flatpak / build-publish (push) Failing after 8m7s
docker / deploy-docs (push) Successful in 22s
release / apple (push) Successful in 9m3s
windows-host / package (push) Failing after 11m41s
deb / build-publish (push) Successful in 13m34s
ci / rust (push) Failing after 14m2s
arch / build-publish (push) Successful in 14m59s
android / android (push) Successful in 16m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m16s
apple / screenshots (push) Successful in 6m38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m38s
`is_transient_io` only recognized `ENOBUFS` on unix; on Windows the
`#[cfg(not(unix))]` arm returned `false`, so `WSAENOBUFS` (10055) — which
Rust maps to `ErrorKind::Uncategorized`, missing the `WouldBlock` arm —
propagated out of the USO `send_gso` path through `Session::send_sealed`
and killed the session with `native::stream` "send failed — stopping
stream". A high-bitrate keyframe burst (one `WSASendMsg` USO super-buffer
is up to ~512 segments) momentarily exhausts the socket send buffer / AFD
non-paged pool; it's a lossy drop that FEC + the next frame recover,
exactly like the unix `ath11k` `ENOBUFS` case the classifier already
handles. Fires independently of client platform (Windows/macOS clients
both saw the crash) because it's the Windows *host's* send socket.

Add a `#[cfg(windows)]` arm matching `WSAENOBUFS` plus the
`WSAENET*`/`WSAEHOST*` network-path family (the Windows counterparts of
the droppable unix set), and extend the classifier test with per-platform
raw-errno coverage so the Windows CI runner exercises the 10055 path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:43:15 +02:00
enricobuehler 11cca33300 fix(host/hooks): honor hand-edits to hooks.json without a restart
android / android (push) Successful in 19m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m51s
windows-host / package (push) Successful in 18m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m58s
ci / rust (push) Failing after 14m6s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m21s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m56s
apple / screenshots (push) Successful in 6m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 12m33s
arch / build-publish (push) Successful in 18m31s
docs/automation.md promises 'changes apply immediately, no restart' for BOTH
config paths, but the store loaded hooks.json once (OnceLock) — only PUT
/api/v1/hooks applied live, and a hand-edited file silently did nothing until
the next host start. Since the file is the accessible path most users will
take, make it real: get() re-stats the file per event (mtime + length) and
re-reads it on change, with the same lenient contract as startup (missing =
no hooks, invalid = disabled loudly). set() records the identity it wrote so
the API path doesn't trigger a spurious reload.

Validated live on the 0.13.0 host (.21): full connect/disconnect lifecycle
fires exec hooks (client.*/session.*/stream.*) — the reload gap was found
setting that test up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:53:25 +02:00
4 changed files with 185 additions and 16 deletions
+11 -2
View File
@@ -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
+70 -2
View File
@@ -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.
+16 -3
View File
@@ -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.
+88 -9
View File
@@ -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();