Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e1e5aeb4 | |||
| 1262fec2ae | |||
| ae67315804 | |||
| 256244430b | |||
| 589d48a975 | |||
| 424bab1c3a | |||
| 5a27c02738 | |||
| d5a0f5012b | |||
| d833cff470 | |||
| 34ec57cb52 | |||
| cd36c46388 | |||
| 59b6d3796b | |||
| 78dba293a8 | |||
| cfdec2729d | |||
| 6dc195f982 | |||
| da259d5b79 | |||
| 4094f6208d | |||
| 0ed8ca90f2 | |||
| 8d72e1d27e | |||
| c15c80718a | |||
| bb58fde369 | |||
| de7b54d4e0 | |||
| 76ac3cf867 | |||
| f4ec18d528 | |||
| ce51a2ba74 | |||
| eacaaa5cd8 | |||
| e0a1edb9d4 | |||
| a53369bf21 | |||
| 3213c1b61c | |||
| b3adfb3a56 |
@@ -73,8 +73,34 @@ jobs:
|
|||||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||||
# there, right before the first `flatpak` network call.
|
# there, right before the first `flatpak` network call.
|
||||||
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
|
||||||
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
run: |
|
||||||
|
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||||
|
# Resolve over TCP instead of UDP. The documented root cause of the flathub
|
||||||
|
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
|
||||||
|
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
|
||||||
|
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
|
||||||
|
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
|
||||||
|
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
|
||||||
|
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
|
||||||
|
# each needing a manual re-run.
|
||||||
|
#
|
||||||
|
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
|
||||||
|
# silently lost under load. Same resolver, same search path — only the transport
|
||||||
|
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
|
||||||
|
# NO extra nameservers, which would risk answering an internal name from a public
|
||||||
|
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
|
||||||
|
# retry.sh stays as the backstop for genuine upstream blips.
|
||||||
|
#
|
||||||
|
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
|
||||||
|
# DNS tuning that cannot be applied must not be what fails the release build — that
|
||||||
|
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
|
||||||
|
# today's behaviour, which retry.sh already covers.
|
||||||
|
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
|
||||||
|
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|
||||||
|
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
|
||||||
|
fi
|
||||||
|
cat /etc/resolv.conf || true
|
||||||
|
|
||||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
|
||||||
|
# (https://git.unom.io/api/packages/unom/npm/).
|
||||||
|
#
|
||||||
|
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
|
||||||
|
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
|
||||||
|
#
|
||||||
|
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
|
||||||
|
# built BEFORE the kit's `bun install` copies it.
|
||||||
|
#
|
||||||
|
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
|
||||||
|
name: plugin-kit-publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['plugin-kit-v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: oven/bun:1
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
||||||
|
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
||||||
|
- name: Install git + node + CA certs
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build the SDK (file:../sdk dependency source)
|
||||||
|
working-directory: sdk
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile --ignore-scripts
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: plugin-kit
|
||||||
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
working-directory: plugin-kit
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
working-directory: plugin-kit
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
- name: Build (dist/ JS + .d.ts + theme.css)
|
||||||
|
working-directory: plugin-kit
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Tag matches package version
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
working-directory: plugin-kit
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
|
||||||
|
PKG="$(node -p "require('./package.json').version")"
|
||||||
|
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
||||||
|
|
||||||
|
- name: Publish to Gitea registry
|
||||||
|
working-directory: plugin-kit
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||||
|
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
||||||
|
bun publish
|
||||||
@@ -149,6 +149,26 @@ jobs:
|
|||||||
# inherits this from the env during the xcframework build).
|
# inherits this from the env during the xcframework build).
|
||||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Pin + prune Xcode DerivedData
|
||||||
|
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
||||||
|
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
||||||
|
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||||
|
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||||
|
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||||
|
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||||
|
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||||
|
run: |
|
||||||
|
DD="$HOME/ci/derived-data/release"
|
||||||
|
mkdir -p "$DD"
|
||||||
|
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||||
|
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||||
|
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||||
|
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
||||||
|
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
||||||
|
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
@@ -176,6 +196,7 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
@@ -273,6 +294,7 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
@@ -336,6 +358,7 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
@@ -394,6 +417,7 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
|
|||||||
@@ -856,7 +856,15 @@ pub fn show(
|
|||||||
s.render_scale =
|
s.render_scale =
|
||||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
||||||
|
// session, hand-edited or written by another client): it displays as "Automatic", and
|
||||||
|
// writing that back would silently erase it just by opening + closing the dialog.
|
||||||
|
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
||||||
|
// a listed one to begin with.
|
||||||
|
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
||||||
|
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||||
|
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||||
|
}
|
||||||
s.touch_mode =
|
s.touch_mode =
|
||||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||||
s.forward_pad = chosen_pin.borrow().clone();
|
s.forward_pad = chosen_pin.borrow().clone();
|
||||||
|
|||||||
+10
-12
@@ -458,7 +458,11 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
),
|
),
|
||||||
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
||||||
}
|
}
|
||||||
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
|
let (mut send, recv) = conn.open_bi().await.context("open control stream")?;
|
||||||
|
// Frame every read on the control stream through the resumable reader, exactly as the client
|
||||||
|
// pump does: `clock_sync` bounds each read with a timeout, and a frame straddling two wakeups
|
||||||
|
// would otherwise leave the stream permanently misaligned for the rest of the run.
|
||||||
|
let mut recv = io::MsgReader::new(recv);
|
||||||
|
|
||||||
io::write_msg(
|
io::write_msg(
|
||||||
&mut send,
|
&mut send,
|
||||||
@@ -513,8 +517,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
|
let welcome =
|
||||||
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = ?welcome.mode,
|
mode = ?welcome.mode,
|
||||||
fec = ?welcome.fec,
|
fec = ?welcome.fec,
|
||||||
@@ -629,10 +633,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("Reconfigure write failed");
|
tracing::error!("Reconfigure write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match io::read_msg(&mut rr)
|
match rr.read_msg().await.map(|b| Reconfigured::decode(&b)) {
|
||||||
.await
|
|
||||||
.map(|b| Reconfigured::decode(&b))
|
|
||||||
{
|
|
||||||
Ok(Ok(ack)) if ack.accepted => {
|
Ok(Ok(ack)) if ack.accepted => {
|
||||||
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
||||||
}
|
}
|
||||||
@@ -685,10 +686,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("SetBitrate write failed");
|
tracing::error!("SetBitrate write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match io::read_msg(&mut rr)
|
match rr.read_msg().await.map(|b| BitrateChanged::decode(&b)) {
|
||||||
.await
|
|
||||||
.map(|b| BitrateChanged::decode(&b))
|
|
||||||
{
|
|
||||||
Ok(Ok(ack)) => tracing::info!(
|
Ok(Ok(ack)) => tracing::info!(
|
||||||
applied_kbps = ack.bitrate_kbps,
|
applied_kbps = ack.bitrate_kbps,
|
||||||
"BITRATE CHANGE acked by host"
|
"BITRATE CHANGE acked by host"
|
||||||
@@ -750,7 +748,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tracing::error!("ProbeRequest write failed");
|
tracing::error!("ProbeRequest write failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
let res = match sr.read_msg().await.map(|b| ProbeResult::decode(&b)) {
|
||||||
Ok(Ok(r)) => r,
|
Ok(Ok(r)) => r,
|
||||||
other => {
|
other => {
|
||||||
tracing::error!(?other, "bad ProbeResult");
|
tracing::error!(?other, "bad ProbeResult");
|
||||||
|
|||||||
@@ -1318,8 +1318,7 @@ mod pipewire {
|
|||||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
||||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
||||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
||||||
let meta =
|
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
||||||
unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor as u32) };
|
|
||||||
if meta.is_null() {
|
if meta.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ pub struct EncoderCaps {
|
|||||||
|
|
||||||
/// A hardware encoder. One per session; runs on the encode thread.
|
/// A hardware encoder. One per session; runs on the encode thread.
|
||||||
pub trait Encoder: Send {
|
pub trait Encoder: Send {
|
||||||
|
/// Submit one captured frame for encoding. Lifetime contract: the caller must keep `frame`
|
||||||
|
/// (and its GPU payload) alive until this frame's AU has been returned by
|
||||||
|
/// [`poll`](Self::poll) — a stream-ordered backend (Linux direct-NVENC's IO-stream binding)
|
||||||
|
/// may still be reading the payload asynchronously after `submit` returns. Both host encode
|
||||||
|
/// loops already hold the frame across their poll drain; new callers must do the same.
|
||||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||||
@@ -264,6 +269,15 @@ pub trait Encoder: Send {
|
|||||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
|
||||||
|
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||||
|
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||||
|
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
||||||
|
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
||||||
|
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
||||||
|
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
/// Pull the next encoded AU if one is ready.
|
/// Pull the next encoded AU if one is ready.
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||||
|
|||||||
@@ -826,7 +826,7 @@ impl NvencEncoder {
|
|||||||
(*f).linesize[i] as usize,
|
(*f).linesize[i] as usize,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts)
|
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
|
||||||
} else if self.want_444 {
|
} else if self.want_444 {
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
bail!(
|
bail!(
|
||||||
@@ -839,11 +839,11 @@ impl NvencEncoder {
|
|||||||
let y_pitch = (*f).linesize[0] as usize;
|
let y_pitch = (*f).linesize[0] as usize;
|
||||||
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let uv_pitch = (*f).linesize[1] as usize;
|
let uv_pitch = (*f).linesize[1] as usize;
|
||||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch)
|
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
|
||||||
} else {
|
} else {
|
||||||
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let dst_pitch = (*f).linesize[0] as usize;
|
let dst_pitch = (*f).linesize[0] as usize;
|
||||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch)
|
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
|
||||||
};
|
};
|
||||||
if let Err(e) = copy_res {
|
if let Err(e) = copy_res {
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
|
|||||||
@@ -16,12 +16,20 @@
|
|||||||
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
|
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
|
||||||
//! device→device copied into the current ring slot (via the existing `copy_*_to_device`
|
//! device→device copied into the current ring slot (via the existing `copy_*_to_device`
|
||||||
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
|
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
|
||||||
//! (NVENC rejects a null-`buf[0]` frame and its CUDADEVICEPTR registration cache is bounded +
|
//! (NVENC rejects a null-`buf[0]` frame; the captured buffer is worker-owned CUDA-IPC memory
|
||||||
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
|
//! recycled on drop, so registering it directly needs a contiguous worker-pool layout + a
|
||||||
//! zero regression versus today; true zero-copy input registration is a follow-up.
|
//! registration↔IPC-mapping lifetime tie — the true zero-copy follow-up, plan §7 LN2 v2).
|
||||||
|
//! **Stream-ordered submit** (default, `PUNKTFUNK_NVENC_STREAM_ORDERED=0` reverts): the
|
||||||
|
//! session's IO streams are bound to the encode thread's copy stream
|
||||||
|
//! (`NvEncSetIOCudaStreams`), so in sync-retrieve depth-1 use the copy + cursor blend enqueue
|
||||||
|
//! with NO per-frame `cuStreamSynchronize` and the encode orders after them on the stream —
|
||||||
|
//! the submit path's CPU stalls are gone even though the copy itself remains.
|
||||||
//!
|
//!
|
||||||
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
|
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC`: `1` = always, `0` = never, unset =
|
||||||
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
|
//! **adaptive** — engaged by the session loop's contention escalation via
|
||||||
|
//! [`Encoder::set_pipelined`] when depth-1 can't hold cadence; at depth-1 it costs ~one loop
|
||||||
|
//! tick of latency, which is why it is not simply on. gpu-contention plan §5.B, latency plan
|
||||||
|
//! T2.2/§7 LN3): NVENC *async mode*
|
||||||
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
|
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
|
||||||
//! but the NVENC guide's threading model still applies: the main thread should only *submit*
|
//! but the NVENC guide's threading model still applies: the main thread should only *submit*
|
||||||
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
|
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
|
||||||
@@ -120,6 +128,14 @@ struct EncodeApi {
|
|||||||
encode_picture:
|
encode_picture:
|
||||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
|
||||||
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
||||||
|
/// `NvEncSetIOCudaStreams` — binds the session's input/output ordering to a CUDA stream so the
|
||||||
|
/// input copy + cursor blend can enqueue without a CPU sync (stream-ordered submit). The two
|
||||||
|
/// `NV_ENC_CUSTREAM_PTR` args are pointers TO `CUstream` values.
|
||||||
|
set_io_cuda_streams: unsafe extern "C" fn(
|
||||||
|
*mut c_void,
|
||||||
|
nv::NV_ENC_CUSTREAM_PTR,
|
||||||
|
nv::NV_ENC_CUSTREAM_PTR,
|
||||||
|
) -> nv::NVENCSTATUS,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
|
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
|
||||||
@@ -212,6 +228,7 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
|
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
|
||||||
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
|
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
|
||||||
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
|
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
|
||||||
|
set_io_cuda_streams: list.nvEncSetIOCudaStreams.ok_or(MISSING)?,
|
||||||
};
|
};
|
||||||
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
|
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
|
||||||
Ok(api)
|
Ok(api)
|
||||||
@@ -223,15 +240,25 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
/// bitstream/ring slot is never reused mid-encode.
|
/// bitstream/ring slot is never reused mid-encode.
|
||||||
const POOL: usize = 8;
|
const POOL: usize = 8;
|
||||||
|
|
||||||
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the
|
/// The operator's `PUNKTFUNK_NVENC_ASYNC` intent (the SAME knob as the Windows backend):
|
||||||
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in
|
/// `Some(true)` = force the two-thread retrieve from session open — note that at the Linux
|
||||||
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync
|
/// default pipeline depth of 1 this adds ~one loop tick of latency (the non-blocking poll's AU
|
||||||
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting
|
/// rides the next tick), so it only pays under GPU contention; `Some(false)` = never (also
|
||||||
/// config to fail the open.
|
/// vetoes the session loop's contention escalation via [`Encoder::set_pipelined`]); `None`
|
||||||
|
/// (unset) = adaptive — off until the session loop escalates on sustained cadence overrun.
|
||||||
|
/// Unlike Windows this changes NO session parameter (Linux stays sync mode; only the blocking
|
||||||
|
/// lock moves off the encode thread), so there is no async-rejecting config to fail the open.
|
||||||
|
fn async_retrieve_env() -> Option<bool> {
|
||||||
|
match std::env::var("PUNKTFUNK_NVENC_ASYNC") {
|
||||||
|
Ok(v) if matches!(v.trim(), "1" | "true" | "yes" | "on") => Some(true),
|
||||||
|
Ok(v) if matches!(v.trim(), "0" | "false" | "no" | "off") => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operator forced the two-thread retrieve on from session open (see [`async_retrieve_env`]).
|
||||||
fn async_retrieve_requested() -> bool {
|
fn async_retrieve_requested() -> bool {
|
||||||
std::env::var("PUNKTFUNK_NVENC_ASYNC")
|
async_retrieve_env() == Some(true)
|
||||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
||||||
@@ -245,6 +272,18 @@ fn async_inflight_cap() -> usize {
|
|||||||
.clamp(2, POOL - 1)
|
.clamp(2, POOL - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
|
||||||
|
/// blocking copies). With the session's IO streams bound to the encode thread's copy stream
|
||||||
|
/// (`NvEncSetIOCudaStreams`), the input copy + cursor blend enqueue with NO CPU sync and
|
||||||
|
/// `encode_picture` orders after them on the stream — deleting the 1–3 per-frame
|
||||||
|
/// `cuStreamSynchronize` stalls from the submit path (latency plan §7 LN2). Sync-retrieve mode
|
||||||
|
/// only, and only while nothing is in flight (see the gate in [`Encoder::submit`]).
|
||||||
|
fn stream_ordered_requested() -> bool {
|
||||||
|
std::env::var("PUNKTFUNK_NVENC_STREAM_ORDERED")
|
||||||
|
.map(|v| v.trim() != "0")
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
|
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
|
||||||
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
|
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
|
||||||
/// the session it belongs to is destroyed).
|
/// the session it belongs to is destroyed).
|
||||||
@@ -302,7 +341,14 @@ fn retrieve_loop(
|
|||||||
if let Err(e) = cuda::make_current() {
|
if let Err(e) = cuda::make_current() {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
||||||
}
|
}
|
||||||
|
let mut jobs: u64 = 0;
|
||||||
while let Ok(job) = work_rx.recv() {
|
while let Ok(job) = work_rx.recv() {
|
||||||
|
// In two-thread mode the host loop's `wait_us` wraps a non-blocking poll, so the real
|
||||||
|
// encode wait (scheduling + ASIC) is measured by NO timer there — sample it here instead
|
||||||
|
// (same PUNKTFUNK_PERF cadence as the submit split).
|
||||||
|
let sample = pf_host_config::config().perf && jobs % 120 == 0;
|
||||||
|
jobs += 1;
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
|
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
|
||||||
// targeted; both it and the session stay valid until `teardown`, which joins this thread
|
// targeted; both it and the session stay valid until `teardown`, which joins this thread
|
||||||
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
|
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
|
||||||
@@ -341,6 +387,16 @@ fn retrieve_loop(
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if sample {
|
||||||
|
if let Ok((data, _)) = &result {
|
||||||
|
tracing::info!(
|
||||||
|
lock_us = t0.elapsed().as_micros() as u64,
|
||||||
|
au_kib = (data.len() / 1024) as u64,
|
||||||
|
"NVENC retrieve lock (sampled): blocking lock_bitstream + AU copy on \
|
||||||
|
pf-nvenc-out (the async-mode encode wait)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
||||||
break; // encoder side gone (teardown drains us via join)
|
break; // encoder side gone (teardown drains us via join)
|
||||||
}
|
}
|
||||||
@@ -396,6 +452,9 @@ pub struct NvencCudaEncoder {
|
|||||||
/// submit). Empty until the session is initialized.
|
/// submit). Empty until the session is initialized.
|
||||||
ring: Vec<RingSlot>,
|
ring: Vec<RingSlot>,
|
||||||
next: usize,
|
next: usize,
|
||||||
|
/// Frames submitted over the encoder's lifetime (never reset, unlike `next`) — drives the
|
||||||
|
/// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter.
|
||||||
|
frames: u64,
|
||||||
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
|
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
|
||||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||||
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
||||||
@@ -440,6 +499,19 @@ pub struct NvencCudaEncoder {
|
|||||||
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
||||||
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
||||||
async_rt: Option<AsyncRetrieve>,
|
async_rt: Option<AsyncRetrieve>,
|
||||||
|
/// The session loop escalated into pipelined retrieve ([`Encoder::set_pipelined`], the
|
||||||
|
/// contention analog of the capturer depth escalation). Sticky across session rebuilds
|
||||||
|
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
||||||
|
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
||||||
|
want_async: bool,
|
||||||
|
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
||||||
|
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
||||||
|
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
||||||
|
/// session is destroyed.
|
||||||
|
io_stream: *mut *mut c_void,
|
||||||
|
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
|
||||||
|
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
|
||||||
|
stream_ordered: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||||
@@ -501,6 +573,7 @@ impl NvencCudaEncoder {
|
|||||||
hdr_meta: None,
|
hdr_meta: None,
|
||||||
ring: Vec::new(),
|
ring: Vec::new(),
|
||||||
next: 0,
|
next: 0,
|
||||||
|
frames: 0,
|
||||||
bitstreams: Vec::new(),
|
bitstreams: Vec::new(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
frame_idx: 0,
|
frame_idx: 0,
|
||||||
@@ -518,9 +591,35 @@ impl NvencCudaEncoder {
|
|||||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||||
last_rfi_range: None,
|
last_rfi_range: None,
|
||||||
async_rt: None,
|
async_rt: None,
|
||||||
|
want_async: false,
|
||||||
|
io_stream: ptr::null_mut(),
|
||||||
|
stream_ordered: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Engage the escalated pipelined retrieve at a safe point: nothing in flight, and — because
|
||||||
|
/// a live session has its IO streams bound for stream-ordered submit, whose output-stream
|
||||||
|
/// semantics would make every later stream op wait on the previous encode and so serialize a
|
||||||
|
/// pipelined session — via a clean session rebuild WITHOUT the binding (the re-open's first
|
||||||
|
/// frame is the standard session-opening IDR). No-op until [`want_async`](Self::want_async)
|
||||||
|
/// is set and `pending` drains.
|
||||||
|
fn maybe_engage_async(&mut self) {
|
||||||
|
if !self.want_async || self.async_rt.is_some() || !self.pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.inited {
|
||||||
|
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight; `teardown` handles
|
||||||
|
// exactly this live-session state (and a torn-down encoder lazily re-inits on the
|
||||||
|
// next submit, which spawns the retrieve thread and skips the IO-stream arming).
|
||||||
|
unsafe { self.teardown() };
|
||||||
|
tracing::info!(
|
||||||
|
"NVENC pipelined-retrieve escalation: rebuilding the session without the \
|
||||||
|
IO-stream binding (stream-ordered submit and two-thread retrieve are mutually \
|
||||||
|
exclusive); next frame opens with an IDR"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
||||||
unsafe fn teardown(&mut self) {
|
unsafe fn teardown(&mut self) {
|
||||||
if self.encoder.is_null() {
|
if self.encoder.is_null() {
|
||||||
@@ -557,6 +656,14 @@ impl NvencCudaEncoder {
|
|||||||
session's slot toward the concurrent-session cap"
|
session's slot toward the concurrent-session cap"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The boxed CUstream the IO-stream binding pointed at — freed only now, AFTER the session
|
||||||
|
// that referenced it is destroyed (created by `Box::into_raw` in `init_session`, freed
|
||||||
|
// exactly once here; `io_stream` is nulled so a re-init can't double-free).
|
||||||
|
if !self.io_stream.is_null() {
|
||||||
|
drop(Box::from_raw(self.io_stream));
|
||||||
|
self.io_stream = ptr::null_mut();
|
||||||
|
}
|
||||||
|
self.stream_ordered = false;
|
||||||
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
||||||
self.bitstreams.clear();
|
self.bitstreams.clear();
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
@@ -619,6 +726,10 @@ impl NvencCudaEncoder {
|
|||||||
enc,
|
enc,
|
||||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
||||||
);
|
);
|
||||||
|
// Sub-frame-output prerequisites (latency plan §7 LN1): logged for fleet visibility now,
|
||||||
|
// consumed when slice-level readback lands. Not stored — LN1 re-probes when it configures.
|
||||||
|
let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK);
|
||||||
|
let dyn_slice = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_DYNAMIC_SLICE_MODE);
|
||||||
let _ = (api().destroy_encoder)(enc);
|
let _ = (api().destroy_encoder)(enc);
|
||||||
|
|
||||||
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
||||||
@@ -641,6 +752,8 @@ impl NvencCudaEncoder {
|
|||||||
rfi = self.rfi_supported,
|
rfi = self.rfi_supported,
|
||||||
custom_vbv = self.custom_vbv,
|
custom_vbv = self.custom_vbv,
|
||||||
yuv444 = self.yuv444_supported,
|
yuv444 = self.yuv444_supported,
|
||||||
|
subframe_readback = subframe != 0,
|
||||||
|
dynamic_slice = dyn_slice != 0,
|
||||||
max = %format!("{wmax}x{hmax}"),
|
max = %format!("{wmax}x{hmax}"),
|
||||||
"NVENC (Linux direct) capabilities probed"
|
"NVENC (Linux direct) capabilities probed"
|
||||||
);
|
);
|
||||||
@@ -807,7 +920,11 @@ impl NvencCudaEncoder {
|
|||||||
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
|
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
|
||||||
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
|
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
|
||||||
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
|
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
|
||||||
// live pitched CUDA allocation on the shared context. No handle escapes the encode thread.
|
// live pitched CUDA allocation on the shared context. `set_io_cuda_streams` takes `enc` plus
|
||||||
|
// two pointers to the boxed live `CUstream` (`Box::into_raw`), which outlives the session —
|
||||||
|
// freed exactly once: in `teardown` after `destroy_encoder` when armed, or via
|
||||||
|
// `Box::from_raw` right here on the rejection path (where `io_stream` is never set). No
|
||||||
|
// handle escapes the encode thread.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Bind to the shared CUDA context; make it current on this (encode) thread for both the
|
// Bind to the shared CUDA context; make it current on this (encode) thread for both the
|
||||||
// session open and every subsequent device→device input copy.
|
// session open and every subsequent device→device input copy.
|
||||||
@@ -960,13 +1077,53 @@ impl NvencCudaEncoder {
|
|||||||
self.inited = true;
|
self.inited = true;
|
||||||
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
||||||
// session parameter differs — teardown/rebuild always stops it before destroy.
|
// session parameter differs — teardown/rebuild always stops it before destroy.
|
||||||
if async_retrieve_requested() {
|
if async_retrieve_requested() || self.want_async {
|
||||||
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
depth = async_inflight_cap(),
|
depth = async_inflight_cap(),
|
||||||
|
escalated = self.want_async,
|
||||||
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Stream-ordered submit (latency plan §7 LN2): bind the session's IO streams to this
|
||||||
|
// thread's copy stream so the input copy + cursor blend enqueue with no CPU sync and
|
||||||
|
// `encode_picture` orders after them. Same stream both ways: input-stream semantics
|
||||||
|
// start the encode only after our enqueued copies, output-stream semantics insert the
|
||||||
|
// encode's completion INTO the stream — so later stream work (the next frame's copy
|
||||||
|
// into a reused ring slot) also waits for it. Sync-retrieve mode only: in two-thread
|
||||||
|
// mode the captured buffer may be recycled after `submit` returns while the stream
|
||||||
|
// still holds its copy (the blocking copies are the lifetime guarantee there).
|
||||||
|
if self.async_rt.is_none() && stream_ordered_requested() {
|
||||||
|
let stream = cuda::copy_stream_handle();
|
||||||
|
if !stream.is_null() {
|
||||||
|
// The pointee must outlive the session (the driver takes CUstream POINTERS) —
|
||||||
|
// box it; `teardown` frees it after `destroy_encoder`.
|
||||||
|
let holder = Box::into_raw(Box::new(stream));
|
||||||
|
match (api().set_io_cuda_streams)(
|
||||||
|
enc,
|
||||||
|
holder as nv::NV_ENC_CUSTREAM_PTR,
|
||||||
|
holder as nv::NV_ENC_CUSTREAM_PTR,
|
||||||
|
)
|
||||||
|
.nv_ok()
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
self.io_stream = holder;
|
||||||
|
self.stream_ordered = true;
|
||||||
|
tracing::info!(
|
||||||
|
"NVENC stream-ordered submit armed (IO streams bound — no CPU \
|
||||||
|
sync in the submit path)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(Box::from_raw(holder));
|
||||||
|
tracing::debug!(
|
||||||
|
status = ?e,
|
||||||
|
"NvEncSetIOCudaStreams rejected — keeping blocking copies"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||||
bit_depth = self.bit_depth,
|
bit_depth = self.bit_depth,
|
||||||
@@ -980,8 +1137,10 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
|
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
|
||||||
/// on the shared context, synchronized by the copy helpers).
|
/// on the shared context). `sync` blocks until the copy completes (the pre-existing behavior);
|
||||||
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize) -> Result<()> {
|
/// `!sync` enqueues on the encode thread's copy stream and leaves ordering to the session's
|
||||||
|
/// IO-stream binding (stream-ordered submit — see the gate in [`Encoder::submit`]).
|
||||||
|
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize, sync: bool) -> Result<()> {
|
||||||
let s = &self.ring[slot].surface;
|
let s = &self.ring[slot].surface;
|
||||||
let base = s.ptr;
|
let base = s.ptr;
|
||||||
let pitch = s.pitch;
|
let pitch = s.pitch;
|
||||||
@@ -996,16 +1155,16 @@ impl NvencCudaEncoder {
|
|||||||
(base + pitch as u64 * hh, pitch),
|
(base + pitch as u64 * hh, pitch),
|
||||||
(base + 2 * pitch as u64 * hh, pitch),
|
(base + 2 * pitch as u64 * hh, pitch),
|
||||||
];
|
];
|
||||||
cuda::copy_yuv444_to_device(buf, planes)
|
cuda::copy_yuv444_to_device(buf, planes, sync)
|
||||||
}
|
}
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||||
if !buf.is_nv12() {
|
if !buf.is_nv12() {
|
||||||
bail!("NV12 session but the captured buffer has no chroma plane");
|
bail!("NV12 session but the captured buffer has no chroma plane");
|
||||||
}
|
}
|
||||||
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
|
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
|
||||||
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch)
|
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch, sync)
|
||||||
}
|
}
|
||||||
_ => cuda::copy_device_to_device(buf, base, pitch),
|
_ => cuda::copy_device_to_device(buf, base, pitch, sync),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,6 +1211,9 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
|
||||||
|
// (nothing in flight after the previous poll drained).
|
||||||
|
self.maybe_engage_async();
|
||||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||||
let new_fmt = buffer_format(buf);
|
let new_fmt = buffer_format(buf);
|
||||||
@@ -1115,8 +1277,31 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
let slot = self.next % POOL;
|
let slot = self.next % POOL;
|
||||||
self.next += 1;
|
self.next += 1;
|
||||||
|
|
||||||
|
// Sampled breakdown of the submit hot path under PUNKTFUNK_PERF (~1 line per 2 s at
|
||||||
|
// 60 fps, the VAAPI submit-split convention): copy = the per-frame device→device input
|
||||||
|
// copy (the zero-copy-registration target), blend = cursor overlay kernel (0 without a
|
||||||
|
// cursor), map/pic = the NVENC map_input_resource / encode_picture launches. The host
|
||||||
|
// loop's `submit_us` folds all four together; this is what splits them apart.
|
||||||
|
let sample = pf_host_config::config().perf && self.frames % 120 == 0;
|
||||||
|
self.frames += 1;
|
||||||
|
|
||||||
|
// Stream-ordered fast path (§7 LN2): enqueue the copy + blend with no CPU sync and let the
|
||||||
|
// IO-stream binding order `encode_picture` after them — but ONLY while nothing is in
|
||||||
|
// flight (true depth-1 usage). The gate is what makes this sound: with `pending` empty,
|
||||||
|
// every prior encode was drained by a blocking `poll`, so (a) the ring slot being reused
|
||||||
|
// was fully read, and (b) the caller still holds this frame's payload across the matching
|
||||||
|
// `poll` (both host loops do — see `Encoder::submit`'s doc), which blocks until the encode
|
||||||
|
// (and therefore the enqueued copy) completed. A pipelined caller (pending non-empty)
|
||||||
|
// falls back to the blocking copy so an early-recycled source can never be read late.
|
||||||
|
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
||||||
|
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
||||||
|
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
||||||
|
let ordered = self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
|
||||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||||
self.copy_into_slot(buf, slot)?;
|
self.copy_into_slot(buf, slot, !ordered)?;
|
||||||
|
let t_copy = t0.elapsed();
|
||||||
|
|
||||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
|
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
|
||||||
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
|
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
|
||||||
@@ -1159,12 +1344,12 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
let (w, h) = (self.width, s.height);
|
let (w, h) = (self.width, s.height);
|
||||||
let r = match self.buffer_fmt {
|
let r = match self.buffer_fmt {
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
|
||||||
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
|
||||||
}
|
}
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||||
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
|
||||||
}
|
}
|
||||||
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered),
|
||||||
};
|
};
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
if !self.cursor_blend_warned {
|
if !self.cursor_blend_warned {
|
||||||
@@ -1180,6 +1365,9 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let t_blend = t0.elapsed() - t_copy;
|
||||||
|
let t_map: std::time::Duration;
|
||||||
|
let t_pic: std::time::Duration;
|
||||||
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
|
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
|
||||||
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
|
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
|
||||||
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
|
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
|
||||||
@@ -1187,8 +1375,11 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
|
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
|
||||||
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
|
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
|
||||||
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
||||||
// just filled by the (synchronized) device→device copy and is not overwritten until this slot
|
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
||||||
// is reused POOL submits later, by which time this encode was polled (POOL ≥ in-flight depth).
|
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
||||||
|
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
|
||||||
|
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
||||||
|
// blocking lock additionally proves the enqueued copy completed).
|
||||||
unsafe {
|
unsafe {
|
||||||
let reg = self.ring[slot].reg;
|
let reg = self.ring[slot].reg;
|
||||||
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
|
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
|
||||||
@@ -1196,9 +1387,11 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
registeredResource: reg,
|
registeredResource: reg,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
let tm = std::time::Instant::now();
|
||||||
(api().map_input_resource)(self.encoder, &mut mp)
|
(api().map_input_resource)(self.encoder, &mut mp)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
||||||
|
t_map = tm.elapsed();
|
||||||
|
|
||||||
let pts = self.frame_idx as u64;
|
let pts = self.frame_idx as u64;
|
||||||
self.frame_idx += 1;
|
self.frame_idx += 1;
|
||||||
@@ -1268,9 +1461,11 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let tp = std::time::Instant::now();
|
||||||
(api().encode_picture)(self.encoder, &mut pic)
|
(api().encode_picture)(self.encoder, &mut pic)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||||
|
t_pic = tp.elapsed();
|
||||||
self.pending.push_back((
|
self.pending.push_back((
|
||||||
self.bitstreams[slot],
|
self.bitstreams[slot],
|
||||||
mp.mappedResource,
|
mp.mappedResource,
|
||||||
@@ -1278,6 +1473,16 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
anchor,
|
anchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if sample {
|
||||||
|
tracing::info!(
|
||||||
|
copy_us = t_copy.as_micros() as u64,
|
||||||
|
blend_us = t_blend.as_micros() as u64,
|
||||||
|
map_us = t_map.as_micros() as u64,
|
||||||
|
pic_us = t_pic.as_micros() as u64,
|
||||||
|
"NVENC submit split (sampled): copy=input D2D copy blend=cursor map=map_input \
|
||||||
|
pic=encode_picture launch"
|
||||||
|
);
|
||||||
|
}
|
||||||
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
|
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
|
||||||
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
|
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
|
||||||
if let Some(rt) = &self.async_rt {
|
if let Some(rt) = &self.async_rt {
|
||||||
@@ -1299,6 +1504,21 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
self.force_kf = true;
|
self.force_kf = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||||
|
if !on {
|
||||||
|
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
|
||||||
|
return self.want_async || self.async_rt.is_some();
|
||||||
|
}
|
||||||
|
if async_retrieve_env() == Some(false) {
|
||||||
|
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
||||||
|
}
|
||||||
|
if !self.want_async && self.async_rt.is_none() {
|
||||||
|
self.want_async = true;
|
||||||
|
self.maybe_engage_async();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn caps(&self) -> EncoderCaps {
|
fn caps(&self) -> EncoderCaps {
|
||||||
EncoderCaps {
|
EncoderCaps {
|
||||||
supports_rfi: self.rfi_supported,
|
supports_rfi: self.rfi_supported,
|
||||||
@@ -1892,4 +2112,220 @@ mod tests {
|
|||||||
assert!(got, "recovered encoder must produce an AU");
|
assert!(got, "recovered encoder must produce an AU");
|
||||||
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
|
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ON-HARDWARE (RTX box `.21`): the stream-ordered submit (latency plan §7 LN2) must ARM on a
|
||||||
|
/// default-env session — `NvEncSetIOCudaStreams` accepted, boxed `CUstream` held. Guards
|
||||||
|
/// against a silent fallback to blocking copies: a rejected binding still encodes correctly,
|
||||||
|
/// just with the per-frame CPU syncs back, which no other test would notice.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||||
|
fn nvenc_cuda_stream_ordered_arms() {
|
||||||
|
const W: u32 = 640;
|
||||||
|
const H: u32 = 360;
|
||||||
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
|
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
|
||||||
|
if !stream_ordered_requested() || async_retrieve_requested() {
|
||||||
|
println!("skipped: stream-ordered submit disabled by env");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut enc = NvencCudaEncoder::open(
|
||||||
|
Codec::H265,
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
W,
|
||||||
|
H,
|
||||||
|
60,
|
||||||
|
8_000_000,
|
||||||
|
true,
|
||||||
|
8,
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
)
|
||||||
|
.expect("open NVENC CUDA session");
|
||||||
|
let frame = nv12_frame(W, H, 0);
|
||||||
|
enc.submit_indexed(&frame, 0).expect("submit");
|
||||||
|
let au = enc.poll().expect("poll").expect("AU");
|
||||||
|
assert!(au.keyframe, "opening AU must be the session IDR");
|
||||||
|
assert!(
|
||||||
|
enc.stream_ordered,
|
||||||
|
"IO-stream binding must arm on a default-env session (NvEncSetIOCudaStreams rejected?)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!enc.io_stream.is_null(),
|
||||||
|
"the boxed CUstream must be held while armed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
||||||
|
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
||||||
|
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
||||||
|
/// post-escalation AU is the re-open's session-opening IDR; pipelined `poll` is
|
||||||
|
/// non-blocking, so AUs may ride a later tick).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||||
|
fn nvenc_cuda_pipelined_escalation() {
|
||||||
|
const W: u32 = 1280;
|
||||||
|
const H: u32 = 720;
|
||||||
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
|
if async_retrieve_env() == Some(false) {
|
||||||
|
println!("skipped: PUNKTFUNK_NVENC_ASYNC=0 vetoes the escalation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut enc = NvencCudaEncoder::open(
|
||||||
|
Codec::H265,
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
W,
|
||||||
|
H,
|
||||||
|
60,
|
||||||
|
8_000_000,
|
||||||
|
true,
|
||||||
|
8,
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
)
|
||||||
|
.expect("open NVENC CUDA session");
|
||||||
|
// Steady sync frames first (stream-ordered mode).
|
||||||
|
for i in 0..3u32 {
|
||||||
|
let frame = nv12_frame(W, H, i);
|
||||||
|
enc.submit_indexed(&frame, i).expect("submit");
|
||||||
|
enc.poll().expect("poll").expect("AU");
|
||||||
|
}
|
||||||
|
assert!(enc.async_rt.is_none(), "session starts sync");
|
||||||
|
assert!(enc.set_pipelined(true), "escalation must be accepted");
|
||||||
|
let mut aus = 0usize;
|
||||||
|
let mut first_key = false;
|
||||||
|
for i in 3..13u32 {
|
||||||
|
let frame = nv12_frame(W, H, i);
|
||||||
|
enc.submit_indexed(&frame, i)
|
||||||
|
.expect("submit post-escalation");
|
||||||
|
while let Some(au) = enc.poll().expect("poll") {
|
||||||
|
if aus == 0 {
|
||||||
|
first_key = au.keyframe;
|
||||||
|
}
|
||||||
|
aus += 1;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(3));
|
||||||
|
}
|
||||||
|
// Drain the pipelined tail (bounded).
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
|
||||||
|
while aus < 10 && std::time::Instant::now() < deadline {
|
||||||
|
if enc.poll().expect("poll").is_some() {
|
||||||
|
aus += 1;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
enc.async_rt.is_some(),
|
||||||
|
"retrieve thread must be live after escalation"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!enc.stream_ordered,
|
||||||
|
"IO-stream binding must be gone in pipelined mode"
|
||||||
|
);
|
||||||
|
assert_eq!(aus, 10, "every post-escalation frame must deliver an AU");
|
||||||
|
assert!(first_key, "first post-escalation AU is the re-open IDR");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ON-HARDWARE (RTX box `.21`), MEASUREMENT probe for latency plan §7 LN1 — answers the
|
||||||
|
/// go/no-go question for sub-frame slice output: with `PUNKTFUNK_NVENC_SLICES=4` +
|
||||||
|
/// `PUNKTFUNK_NVENC_SUBFRAME=1`, do slices become READABLE incrementally while the frame is
|
||||||
|
/// still encoding (and with what spacing), or does the driver only publish them at frame
|
||||||
|
/// completion? Spins `lock_bitstream(doNotWait)` against the in-flight bitstream and prints a
|
||||||
|
/// `(t_us, numSlices, bytes)` timeline. Asserts only the config half (4 slices materialize);
|
||||||
|
/// the timeline is the experiment's output — read it with `--nocapture`. Run single-threaded
|
||||||
|
/// (env vars are process-global): `-- --ignored --test-threads=1`.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||||
|
fn nvenc_cuda_subframe_slice_probe() {
|
||||||
|
const W: u32 = 1920;
|
||||||
|
const H: u32 = 1080;
|
||||||
|
struct EnvGuard;
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||||
|
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
|
||||||
|
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||||
|
let _guard = EnvGuard;
|
||||||
|
|
||||||
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
|
let mut enc = NvencCudaEncoder::open(
|
||||||
|
Codec::H265,
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
W,
|
||||||
|
H,
|
||||||
|
60,
|
||||||
|
20_000_000,
|
||||||
|
true,
|
||||||
|
8,
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
)
|
||||||
|
.expect("open NVENC CUDA session");
|
||||||
|
|
||||||
|
// Frame 0 opens the session (IDR) — drain it normally.
|
||||||
|
let frame = nv12_frame(W, H, 0);
|
||||||
|
enc.submit_indexed(&frame, 0).expect("submit opening frame");
|
||||||
|
enc.poll().expect("poll").expect("opening AU");
|
||||||
|
|
||||||
|
// Frame 1: spin doNotWait locks against the in-flight bitstream BEFORE the blocking poll.
|
||||||
|
let frame = nv12_frame(W, H, 1);
|
||||||
|
enc.submit_indexed(&frame, 1).expect("submit probed frame");
|
||||||
|
let bs = enc.pending.back().expect("in-flight entry").0;
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let mut timeline: Vec<(u64, nv::NVENCSTATUS, u32, u32)> = Vec::new();
|
||||||
|
let mut offsets = [0u32; 32];
|
||||||
|
loop {
|
||||||
|
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||||
|
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||||
|
outputBitstream: bs,
|
||||||
|
sliceOffsets: offsets.as_mut_ptr(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
lock.set_doNotWait(1);
|
||||||
|
// SAFETY: `bs` is the pool bitstream the just-submitted `encode_picture` targets and
|
||||||
|
// the session is live for the whole test; `lock` (version set, doNotWait) and
|
||||||
|
// `offsets` are live stack locals across the synchronous call; a successful lock is
|
||||||
|
// unlocked before the next iteration reuses the struct. `reportSliceOffsets` was
|
||||||
|
// armed at init so `sliceOffsets` may be written up to `numSlices` ≤ 32 entries
|
||||||
|
// (sliceModeData = 4).
|
||||||
|
let (status, n, bytes) = unsafe {
|
||||||
|
let st = (api().lock_bitstream)(enc.encoder, &mut lock);
|
||||||
|
let ok = st == nv::NVENCSTATUS::NV_ENC_SUCCESS;
|
||||||
|
let (n, b) = if ok {
|
||||||
|
(lock.numSlices, lock.bitstreamSizeInBytes)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
};
|
||||||
|
if ok {
|
||||||
|
let _ = (api().unlock_bitstream)(enc.encoder, bs);
|
||||||
|
}
|
||||||
|
(st, n, b)
|
||||||
|
};
|
||||||
|
let t_us = t0.elapsed().as_micros() as u64;
|
||||||
|
timeline.push((t_us, status, n, bytes));
|
||||||
|
// A successful doNotWait lock on a COMPLETE frame reports the final slice count; on
|
||||||
|
// LOCK_BUSY the frame is still encoding. Stop once complete (all 4 slices) or after
|
||||||
|
// a generous 50 ms safety window.
|
||||||
|
if (status == nv::NVENCSTATUS::NV_ENC_SUCCESS && n >= 4) || t_us > 50_000 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_micros(50));
|
||||||
|
}
|
||||||
|
println!("subframe probe timeline (t_us, status, numSlices, bytes):");
|
||||||
|
for (t, st, n, b) in &timeline {
|
||||||
|
println!(" {t:>7} us {st:?} slices={n} bytes={b}");
|
||||||
|
}
|
||||||
|
// Drain the probed frame through the normal path (lock again + unmap) — proves the probe
|
||||||
|
// locks didn't corrupt the session.
|
||||||
|
let au = enc.poll().expect("poll probed frame").expect("probed AU");
|
||||||
|
assert!(!au.data.is_empty(), "probed AU must carry data");
|
||||||
|
let last = timeline.last().expect("at least one sample");
|
||||||
|
assert_eq!(
|
||||||
|
last.2, 4,
|
||||||
|
"4 slices must materialize (PUNKTFUNK_NVENC_SLICES=4 + subframe readback armed)"
|
||||||
|
);
|
||||||
|
// One more frame end-to-end for session health.
|
||||||
|
let frame = nv12_frame(W, H, 2);
|
||||||
|
enc.submit_indexed(&frame, 2).expect("submit follow-up");
|
||||||
|
enc.poll().expect("poll").expect("follow-up AU");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,32 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
|||||||
d: &pf_frame::DmabufFrame,
|
d: &pf_frame::DmabufFrame,
|
||||||
cw: u32,
|
cw: u32,
|
||||||
ch: u32,
|
ch: u32,
|
||||||
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
|
import_rgb_dmabuf_as(
|
||||||
|
device,
|
||||||
|
ext_fd,
|
||||||
|
mem_props,
|
||||||
|
d,
|
||||||
|
cw,
|
||||||
|
ch,
|
||||||
|
vk::ImageUsageFlags::SAMPLED,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list
|
||||||
|
/// (chained into the image create) — the RGB-direct encode path imports the captured buffer
|
||||||
|
/// as a profiled `VIDEO_ENCODE_SRC` image instead of a sampled one.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||||
|
device: &ash::Device,
|
||||||
|
ext_fd: &ash::khr::external_memory_fd::Device,
|
||||||
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
|
d: &pf_frame::DmabufFrame,
|
||||||
|
cw: u32,
|
||||||
|
ch: u32,
|
||||||
|
usage: vk::ImageUsageFlags,
|
||||||
|
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use std::os::fd::IntoRawFd;
|
use std::os::fd::IntoRawFd;
|
||||||
@@ -90,26 +116,27 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
|||||||
.plane_layouts(&plane);
|
.plane_layouts(&plane);
|
||||||
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
||||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||||
let img = device.create_image(
|
let mut ci = vk::ImageCreateInfo::default()
|
||||||
&vk::ImageCreateInfo::default()
|
.image_type(vk::ImageType::TYPE_2D)
|
||||||
.image_type(vk::ImageType::TYPE_2D)
|
.format(fmt)
|
||||||
.format(fmt)
|
.extent(vk::Extent3D {
|
||||||
.extent(vk::Extent3D {
|
width: cw,
|
||||||
width: cw,
|
height: ch,
|
||||||
height: ch,
|
depth: 1,
|
||||||
depth: 1,
|
})
|
||||||
})
|
.mip_levels(1)
|
||||||
.mip_levels(1)
|
.array_layers(1)
|
||||||
.array_layers(1)
|
.samples(vk::SampleCountFlags::TYPE_1)
|
||||||
.samples(vk::SampleCountFlags::TYPE_1)
|
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
.usage(usage)
|
||||||
.usage(vk::ImageUsageFlags::SAMPLED)
|
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
.push_next(&mut ext)
|
||||||
.push_next(&mut ext)
|
.push_next(&mut drm);
|
||||||
.push_next(&mut drm),
|
if let Some(pl) = profile_list {
|
||||||
None,
|
ci = ci.push_next(pl);
|
||||||
)?;
|
}
|
||||||
|
let img = device.create_image(&ci, None)?;
|
||||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
||||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
||||||
let fd_props = {
|
let fd_props = {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! Vendored `VK_VALVE_video_encode_rgb_conversion` bindings — the RGB→YCbCr encode-source
|
||||||
|
//! extension (Vulkan 1.4.327; RADV since Mesa 26.0, hardware-gated on the VCN EFC front-end
|
||||||
|
//! conversion block). Our pinned `ash 0.38.0+1.3.281` predates it entirely; same vendoring
|
||||||
|
//! rationale as [`vk_av1_encode`](super::vk_av1_encode) — definitions copied from the registry
|
||||||
|
//! so the layouts are correct-by-construction, chained via raw `p_next`. Consumed by
|
||||||
|
//! `vulkan_video.rs`: B0 probes + logs availability (design/vulkan-rgb-direct-encode.md);
|
||||||
|
//! B1 makes the captured BGRx dmabuf the direct encode source with EFC doing the 709-narrow CSC.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use ash::vk;
|
||||||
|
use std::ffi::{c_void, CStr};
|
||||||
|
|
||||||
|
pub const EXTENSION_NAME: &CStr = c"VK_VALVE_video_encode_rgb_conversion";
|
||||||
|
|
||||||
|
// ---------- struct-type (VkStructureType) values — construct via `stype` ----------
|
||||||
|
pub const ST_PHYSICAL_DEVICE_FEATURES: i32 = 1_000_390_000;
|
||||||
|
pub const ST_CAPABILITIES: i32 = 1_000_390_001;
|
||||||
|
pub const ST_PROFILE_INFO: i32 = 1_000_390_002;
|
||||||
|
pub const ST_SESSION_CREATE_INFO: i32 = 1_000_390_003;
|
||||||
|
|
||||||
|
// `VkVideoEncodeRgbModelConversionFlagBitsVALVE`
|
||||||
|
pub const MODEL_RGB_IDENTITY: u32 = 0x01;
|
||||||
|
pub const MODEL_YCBCR_IDENTITY: u32 = 0x02;
|
||||||
|
pub const MODEL_YCBCR_709: u32 = 0x04;
|
||||||
|
pub const MODEL_YCBCR_601: u32 = 0x08;
|
||||||
|
pub const MODEL_YCBCR_2020: u32 = 0x10;
|
||||||
|
// `VkVideoEncodeRgbRangeCompressionFlagBitsVALVE`
|
||||||
|
pub const RANGE_FULL: u32 = 0x01;
|
||||||
|
pub const RANGE_NARROW: u32 = 0x02;
|
||||||
|
// `VkVideoEncodeRgbChromaOffsetFlagBitsVALVE`
|
||||||
|
pub const CHROMA_OFFSET_COSITED_EVEN: u32 = 0x01;
|
||||||
|
pub const CHROMA_OFFSET_MIDPOINT: u32 = 0x02;
|
||||||
|
|
||||||
|
/// `VkPhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE` — chain into
|
||||||
|
/// `VkPhysicalDeviceFeatures2` (query) / `VkDeviceCreateInfo` (enable).
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
|
||||||
|
pub s_type: vk::StructureType,
|
||||||
|
pub p_next: *mut c_void,
|
||||||
|
pub video_encode_rgb_conversion: vk::Bool32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VkVideoEncodeRgbConversionCapabilitiesVALVE` — chain into the
|
||||||
|
/// `vkGetPhysicalDeviceVideoCapabilitiesKHR` output when the queried profile carries
|
||||||
|
/// [`VideoEncodeProfileRgbConversionInfoVALVE`]; reports which conversions the HW does.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct VideoEncodeRgbConversionCapabilitiesVALVE {
|
||||||
|
pub s_type: vk::StructureType,
|
||||||
|
pub p_next: *mut c_void,
|
||||||
|
pub rgb_models: u32,
|
||||||
|
pub rgb_ranges: u32,
|
||||||
|
pub x_chroma_offsets: u32,
|
||||||
|
pub y_chroma_offsets: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VkVideoEncodeProfileRgbConversionInfoVALVE` — part of the video-profile *identity*: every
|
||||||
|
/// consumer of the profile (caps query, format query, session, image profile lists) must carry
|
||||||
|
/// the same chain.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct VideoEncodeProfileRgbConversionInfoVALVE {
|
||||||
|
pub s_type: vk::StructureType,
|
||||||
|
pub p_next: *const c_void,
|
||||||
|
pub perform_encode_rgb_conversion: vk::Bool32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VkVideoEncodeSessionRgbConversionCreateInfoVALVE` — chain into
|
||||||
|
/// `VkVideoSessionCreateInfoKHR`; single-bit selections of the conversion actually performed.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
||||||
|
pub s_type: vk::StructureType,
|
||||||
|
pub p_next: *const c_void,
|
||||||
|
pub rgb_model: u32,
|
||||||
|
pub rgb_range: u32,
|
||||||
|
pub x_chroma_offset: u32,
|
||||||
|
pub y_chroma_offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `vk::StructureType` for a raw `ST_*` constant above.
|
||||||
|
#[inline]
|
||||||
|
pub fn stype(raw: i32) -> vk::StructureType {
|
||||||
|
vk::StructureType::from_raw(raw)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,15 @@ pub(super) fn build_init_params(
|
|||||||
};
|
};
|
||||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||||
init.set_splitEncodeMode(split_mode);
|
init.set_splitEncodeMode(split_mode);
|
||||||
|
// Sub-frame readback (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off): the driver
|
||||||
|
// writes each slice into the output buffer as it completes and reports per-slice offsets, so a
|
||||||
|
// sync-mode consumer can read slices out while the frame is still encoding. Pair with
|
||||||
|
// `PUNKTFUNK_NVENC_SLICES` (a single-slice frame yields nothing to read early).
|
||||||
|
// `reportSliceOffsets` requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
|
||||||
|
if !enable_async && std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() == Ok("1") {
|
||||||
|
init.set_enableSubFrameWrite(1);
|
||||||
|
init.set_reportSliceOffsets(1);
|
||||||
|
}
|
||||||
init
|
init
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +127,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
|||||||
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
|
||||||
cfg.frameIntervalP = 1;
|
cfg.frameIntervalP = 1;
|
||||||
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
|
||||||
|
// Explicit zero reorder delay: with P-only + no lookahead there is no reordering to buffer,
|
||||||
|
// but pin the bit so no preset/driver default can ever slip a frame of reorder delay in.
|
||||||
|
cfg.rcParams.set_zeroReorderDelay(1);
|
||||||
let bps = c.bitrate.min(u32::MAX as u64) as u32;
|
let bps = c.bitrate.min(u32::MAX as u64) as u32;
|
||||||
cfg.rcParams.averageBitRate = bps;
|
cfg.rcParams.averageBitRate = bps;
|
||||||
cfg.rcParams.maxBitRate = bps;
|
cfg.rcParams.maxBitRate = bps;
|
||||||
@@ -146,6 +158,29 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
|||||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-slice frames (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off = the preset's
|
||||||
|
// single slice): `PUNKTFUNK_NVENC_SLICES=N` (2..=32) splits every frame into N slices
|
||||||
|
// (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss
|
||||||
|
// concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC
|
||||||
|
// only — AV1 partitions via tiles, not slices.
|
||||||
|
if let Some(n) = std::env::var("PUNKTFUNK_NVENC_SLICES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.filter(|n| (2..=32).contains(n))
|
||||||
|
{
|
||||||
|
match c.codec {
|
||||||
|
Codec::H264 => {
|
||||||
|
cfg.encodeCodecConfig.h264Config.sliceMode = 3;
|
||||||
|
cfg.encodeCodecConfig.h264Config.sliceModeData = n;
|
||||||
|
}
|
||||||
|
Codec::H265 => {
|
||||||
|
cfg.encodeCodecConfig.hevcConfig.sliceMode = 3;
|
||||||
|
cfg.encodeCodecConfig.hevcConfig.sliceModeData = n;
|
||||||
|
}
|
||||||
|
Codec::Av1 | Codec::PyroWave => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
|
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
|
||||||
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
||||||
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
||||||
|
|||||||
@@ -1343,6 +1343,12 @@ mod vulkan_video;
|
|||||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
#[path = "enc/linux/vk_av1_encode.rs"]
|
#[path = "enc/linux/vk_av1_encode.rs"]
|
||||||
mod vk_av1_encode;
|
mod vk_av1_encode;
|
||||||
|
// Vendored `VK_VALVE_video_encode_rgb_conversion` bindings (host-only) — RGB encode source with
|
||||||
|
// the VCN EFC front-end doing the CSC (design/vulkan-rgb-direct-encode.md). ash 0.38 predates
|
||||||
|
// the extension; same vendoring rationale as `vk_av1_encode`.
|
||||||
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
|
#[path = "enc/linux/vk_valve_rgb.rs"]
|
||||||
|
mod vk_valve_rgb;
|
||||||
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
||||||
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
|||||||
@@ -215,7 +215,11 @@ fn install_wake_handler() {
|
|||||||
// for a single signal; touches only this process's disposition for `WAKE_SIGNAL`.
|
// for a single signal; touches only this process's disposition for `WAKE_SIGNAL`.
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut sa: libc::sigaction = std::mem::zeroed();
|
let mut sa: libc::sigaction = std::mem::zeroed();
|
||||||
sa.sa_sigaction = noop as usize;
|
// Via `*const ()`: casting a function item straight to an integer is what
|
||||||
|
// `clippy::function_casts_as_integer` rejects, and the pointer hop is the documented
|
||||||
|
// way to spell it. `sa_sigaction` is a `usize`-typed handler slot, so the value is
|
||||||
|
// unchanged.
|
||||||
|
sa.sa_sigaction = noop as *const () as usize;
|
||||||
libc::sigemptyset(&mut sa.sa_mask);
|
libc::sigemptyset(&mut sa.sa_mask);
|
||||||
sa.sa_flags = 0;
|
sa.sa_flags = 0;
|
||||||
libc::sigaction(WAKE_SIGNAL, &sa, std::ptr::null_mut());
|
libc::sigaction(WAKE_SIGNAL, &sa, std::ptr::null_mut());
|
||||||
|
|||||||
@@ -251,6 +251,32 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
|||||||
ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
|
ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue `copy` on this thread's priority stream WITHOUT waiting — for stream-ordered consumers
|
||||||
|
/// only (the direct-NVENC submit path with `NvEncSetIOCudaStreams` bound to this stream): the
|
||||||
|
/// stream, not the CPU, orders completion, so the SOURCE must stay valid until the downstream
|
||||||
|
/// stream work (the encode) has finished.
|
||||||
|
unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
||||||
|
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
|
||||||
|
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
|
||||||
|
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
|
||||||
|
if sync {
|
||||||
|
copy_blocking(copy, what)
|
||||||
|
} else {
|
||||||
|
copy_async(copy, what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calling thread's copy/launch stream as a raw handle, for binding external stream-ordering
|
||||||
|
/// (the direct-NVENC `NvEncSetIOCudaStreams` hookup). Null = the NULL stream (priority-stream
|
||||||
|
/// creation failed) — callers should treat null as "stream-ordering unavailable" and keep their
|
||||||
|
/// blocking copies. The shared context must be current on this thread.
|
||||||
|
pub fn copy_stream_handle() -> *mut c_void {
|
||||||
|
copy_stream() // CUstream IS *mut c_void (opaque CUstream_st*)
|
||||||
|
}
|
||||||
|
|
||||||
/// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path.
|
/// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path.
|
||||||
pub const CURSOR_MAX: u32 = 256;
|
pub const CURSOR_MAX: u32 = 256;
|
||||||
|
|
||||||
@@ -354,6 +380,7 @@ impl CursorBlend {
|
|||||||
ch: u32,
|
ch: u32,
|
||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
|
sync: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (mut a_surf, mut a_cur) = (surf, self.cur_buf);
|
let (mut a_surf, mut a_cur) = (surf, self.cur_buf);
|
||||||
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
||||||
@@ -370,7 +397,7 @@ impl CursorBlend {
|
|||||||
&mut a_ox as *mut _ as *mut c_void,
|
&mut a_ox as *mut _ as *mut c_void,
|
||||||
&mut a_oy as *mut _ as *mut c_void,
|
&mut a_oy as *mut _ as *mut c_void,
|
||||||
];
|
];
|
||||||
self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args)
|
self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args, sync)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`.
|
/// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`.
|
||||||
@@ -385,6 +412,7 @@ impl CursorBlend {
|
|||||||
ch: u32,
|
ch: u32,
|
||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
|
sync: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (mut a_base, mut a_cur) = (base, self.cur_buf);
|
let (mut a_base, mut a_cur) = (base, self.cur_buf);
|
||||||
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
|
||||||
@@ -401,7 +429,7 @@ impl CursorBlend {
|
|||||||
&mut a_ox as *mut _ as *mut c_void,
|
&mut a_ox as *mut _ as *mut c_void,
|
||||||
&mut a_oy as *mut _ as *mut c_void,
|
&mut a_oy as *mut _ as *mut c_void,
|
||||||
];
|
];
|
||||||
self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args)
|
self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args, sync)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`).
|
/// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`).
|
||||||
@@ -416,6 +444,7 @@ impl CursorBlend {
|
|||||||
ch: u32,
|
ch: u32,
|
||||||
ox: i32,
|
ox: i32,
|
||||||
oy: i32,
|
oy: i32,
|
||||||
|
sync: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf);
|
let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf);
|
||||||
let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32);
|
let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32);
|
||||||
@@ -441,16 +470,20 @@ impl CursorBlend {
|
|||||||
(a_cw as u32).div_ceil(2),
|
(a_cw as u32).div_ceil(2),
|
||||||
(a_ch as u32).div_ceil(2),
|
(a_ch as u32).div_ceil(2),
|
||||||
&mut args,
|
&mut args,
|
||||||
|
sync,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream, then synchronize.
|
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream; `sync` waits
|
||||||
|
/// for it, `!sync` leaves completion to the stream (stream-ordered consumers only — the
|
||||||
|
/// kernel PARAMETERS are copied at launch time, so the arg locals need not outlive the call).
|
||||||
fn launch(
|
fn launch(
|
||||||
&self,
|
&self,
|
||||||
f: CUfunction,
|
f: CUfunction,
|
||||||
work_w: u32,
|
work_w: u32,
|
||||||
work_h: u32,
|
work_h: u32,
|
||||||
args: &mut [*mut c_void],
|
args: &mut [*mut c_void],
|
||||||
|
sync: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if work_w == 0 || work_h == 0 {
|
if work_w == 0 || work_h == 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -458,9 +491,11 @@ impl CursorBlend {
|
|||||||
const B: u32 = 16;
|
const B: u32 = 16;
|
||||||
let stream = copy_stream();
|
let stream = copy_stream();
|
||||||
// SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live
|
// SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live
|
||||||
// locals whose types match the kernel's C parameters (per the call site above); grid/block
|
// locals whose types match the kernel's C parameters (per the call site above) — CUDA
|
||||||
// dims are non-zero. Launched on the copy stream (ordered after the input-surface copy that
|
// copies the parameter values during `cuLaunchKernel` itself, so they need not outlive
|
||||||
// `copy_into_slot` already synchronized) then synchronized. Requires the context current.
|
// the call. Grid/block dims are non-zero. Launched on the copy stream (ordered after the
|
||||||
|
// input-surface copy issued on the same stream); `sync` waits, `!sync` leaves ordering to
|
||||||
|
// the stream (the NVENC IO-stream binding). Requires the context current.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuLaunchKernel(
|
cuLaunchKernel(
|
||||||
@@ -478,7 +513,10 @@ impl CursorBlend {
|
|||||||
),
|
),
|
||||||
"cuLaunchKernel(cursor)",
|
"cuLaunchKernel(cursor)",
|
||||||
)?;
|
)?;
|
||||||
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")
|
if sync {
|
||||||
|
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1128,10 +1166,13 @@ pub fn copy_mapped_yuv444(
|
|||||||
/// Copy a pitched device buffer into another device region (device→device), e.g. our imported
|
/// Copy a pitched device buffer into another device region (device→device), e.g. our imported
|
||||||
/// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels.
|
/// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels.
|
||||||
/// The caller must have the shared context current on this thread (see [`make_current`]).
|
/// The caller must have the shared context current on this thread (see [`make_current`]).
|
||||||
|
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
|
||||||
|
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
|
||||||
pub fn copy_device_to_device(
|
pub fn copy_device_to_device(
|
||||||
src: &DeviceBuffer,
|
src: &DeviceBuffer,
|
||||||
dst_ptr: CUdeviceptr,
|
dst_ptr: CUdeviceptr,
|
||||||
dst_pitch: usize,
|
dst_pitch: usize,
|
||||||
|
sync: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let copy = CUDA_MEMCPY2D {
|
let copy = CUDA_MEMCPY2D {
|
||||||
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||||
@@ -1144,23 +1185,27 @@ pub fn copy_device_to_device(
|
|||||||
Height: src.height as usize,
|
Height: src.height as usize,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
|
// SAFETY: `copy_issue` is unsafe (issues a CUDA copy); the caller must have the shared
|
||||||
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||||
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
|
// the enqueue: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/`dstPitch` the
|
||||||
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
|
// caller's live region, `width*4`×`height` within both; `sync: false` shifts the source-
|
||||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->dev)") }
|
// lifetime obligation to the caller (documented above). Wrapper → live table.
|
||||||
|
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(dev->dev)", sync) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface
|
/// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface
|
||||||
/// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` +
|
/// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` +
|
||||||
/// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is
|
/// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is
|
||||||
/// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current.
|
/// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current.
|
||||||
|
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
|
||||||
|
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
|
||||||
pub fn copy_nv12_to_device(
|
pub fn copy_nv12_to_device(
|
||||||
src: &DeviceBuffer,
|
src: &DeviceBuffer,
|
||||||
y_dst: CUdeviceptr,
|
y_dst: CUdeviceptr,
|
||||||
y_pitch: usize,
|
y_pitch: usize,
|
||||||
uv_dst: CUdeviceptr,
|
uv_dst: CUdeviceptr,
|
||||||
uv_pitch: usize,
|
uv_pitch: usize,
|
||||||
|
sync: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (src_uv_ptr, src_uv_pitch) = src
|
let (src_uv_ptr, src_uv_pitch) = src
|
||||||
.uv
|
.uv
|
||||||
@@ -1189,15 +1234,16 @@ pub fn copy_nv12_to_device(
|
|||||||
Height: h / 2,
|
Height: h / 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
|
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
|
||||||
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
||||||
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
||||||
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
||||||
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
||||||
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
|
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
|
||||||
|
// to the caller (documented above). Wrappers → live table.
|
||||||
unsafe {
|
unsafe {
|
||||||
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
|
||||||
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
|
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,7 +1251,13 @@ pub fn copy_nv12_to_device(
|
|||||||
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
|
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
|
||||||
/// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the
|
/// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the
|
||||||
/// single allocation. The caller must have the shared context current.
|
/// single allocation. The caller must have the shared context current.
|
||||||
pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]) -> Result<()> {
|
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
|
||||||
|
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
|
||||||
|
pub fn copy_yuv444_to_device(
|
||||||
|
src: &DeviceBuffer,
|
||||||
|
dsts: [(CUdeviceptr, usize); 3],
|
||||||
|
sync: bool,
|
||||||
|
) -> Result<()> {
|
||||||
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
|
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
|
||||||
let w = src.width as usize;
|
let w = src.width as usize;
|
||||||
let h = src.height as usize;
|
let h = src.height as usize;
|
||||||
@@ -1221,12 +1273,13 @@ pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]
|
|||||||
Height: h,
|
Height: h,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: unsafe `copy_blocking` device→device copy; the caller must have the shared
|
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
|
||||||
// context current (documented). `©` is a live local outliving the synchronous call;
|
// context current (documented). `©` is a live local outliving the enqueue;
|
||||||
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
||||||
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
||||||
// both. Wrapper → live table.
|
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
|
||||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
|
// above). Wrapper → live table.
|
||||||
|
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ impl VkBridge {
|
|||||||
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
|
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
|
||||||
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
|
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
|
||||||
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
|
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
|
||||||
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
|
// `exts`, `prio`, `qci`, `gp_info`, and the inline infos) that all live for the duration of
|
||||||
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
|
// the synchronous `create_*`/`enumerate_*` call that reads them — the ladder loop rebuilds
|
||||||
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
|
// `prio`/`gp_info`/`qci`/`exts` fresh per attempt, so every `enabled_extension_names(&exts)`
|
||||||
|
// / `queue_priorities(&prio)` / `push_next(&mut gp_info)` borrow outlives its own
|
||||||
|
// `create_device` call.
|
||||||
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
|
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
|
||||||
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
|
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
|
||||||
// constructor shares nothing across threads.
|
// constructor shares nothing across threads.
|
||||||
@@ -122,23 +124,93 @@ impl VkBridge {
|
|||||||
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
||||||
as u32;
|
as u32;
|
||||||
|
|
||||||
let exts = [
|
// Global-priority queue (latency plan §7 LN4, PyroWave's `ac0e7332` lever for the
|
||||||
|
// VkBridge): the LINEAR/gamescope CSC dispatch shares the SM/compute cores with the
|
||||||
|
// game, so ask for an elevated global priority to get scheduled ahead of it.
|
||||||
|
// `PUNKTFUNK_VK_QUEUE_PRIORITY` = off | high | realtime (default realtime); the
|
||||||
|
// create loop downgrades REALTIME→HIGH→none on NOT_PERMITTED (and retries a plain
|
||||||
|
// create on INITIALIZATION_FAILED) so a refused class never fails the bridge.
|
||||||
|
let gp_ext = std::env::var("PUNKTFUNK_VK_QUEUE_PRIORITY")
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map_or(Some(vk::QueueGlobalPriorityKHR::REALTIME), |v| match v {
|
||||||
|
"off" | "0" => None,
|
||||||
|
"high" => Some(vk::QueueGlobalPriorityKHR::HIGH),
|
||||||
|
_ => Some(vk::QueueGlobalPriorityKHR::REALTIME),
|
||||||
|
})
|
||||||
|
.and_then(|want| {
|
||||||
|
// Enable whichever alias the driver advertises (KHR = the promoted name).
|
||||||
|
let props = instance.enumerate_device_extension_properties(phys).ok()?;
|
||||||
|
let has = |name: &std::ffi::CStr| {
|
||||||
|
props
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.extension_name_as_c_str() == Ok(name))
|
||||||
|
};
|
||||||
|
if has(vk::KHR_GLOBAL_PRIORITY_NAME) {
|
||||||
|
Some((vk::KHR_GLOBAL_PRIORITY_NAME, want))
|
||||||
|
} else if has(vk::EXT_GLOBAL_PRIORITY_NAME) {
|
||||||
|
Some((vk::EXT_GLOBAL_PRIORITY_NAME, want))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let base_exts = [
|
||||||
ash::khr::external_memory_fd::NAME.as_ptr(),
|
ash::khr::external_memory_fd::NAME.as_ptr(),
|
||||||
ash::ext::external_memory_dma_buf::NAME.as_ptr(),
|
ash::ext::external_memory_dma_buf::NAME.as_ptr(),
|
||||||
];
|
];
|
||||||
let prio = [1.0f32];
|
let mut try_priority = gp_ext.map(|(_, want)| want);
|
||||||
let qci = [vk::DeviceQueueCreateInfo::default()
|
let device = loop {
|
||||||
.queue_family_index(qf)
|
let prio = [1.0f32];
|
||||||
.queue_priorities(&prio)];
|
let mut gp_info = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default()
|
||||||
let device = instance
|
.global_priority(try_priority.unwrap_or(vk::QueueGlobalPriorityKHR::MEDIUM));
|
||||||
.create_device(
|
let mut qci0 = vk::DeviceQueueCreateInfo::default()
|
||||||
|
.queue_family_index(qf)
|
||||||
|
.queue_priorities(&prio);
|
||||||
|
let mut exts: Vec<*const std::ffi::c_char> = base_exts.to_vec();
|
||||||
|
if try_priority.is_some() {
|
||||||
|
qci0 = qci0.push_next(&mut gp_info);
|
||||||
|
exts.push(gp_ext.expect("try_priority implies gp_ext").0.as_ptr());
|
||||||
|
}
|
||||||
|
let qci = [qci0];
|
||||||
|
match instance.create_device(
|
||||||
phys,
|
phys,
|
||||||
&vk::DeviceCreateInfo::default()
|
&vk::DeviceCreateInfo::default()
|
||||||
.queue_create_infos(&qci)
|
.queue_create_infos(&qci)
|
||||||
.enabled_extension_names(&exts),
|
.enabled_extension_names(&exts),
|
||||||
None,
|
None,
|
||||||
)
|
) {
|
||||||
.context("vkCreateDevice (external-memory extensions supported?)")?;
|
Ok(d) => {
|
||||||
|
if let Some(p) = try_priority {
|
||||||
|
tracing::info!(
|
||||||
|
priority = ?p,
|
||||||
|
"VkBridge queue at elevated global priority (CSC schedules \
|
||||||
|
ahead of a GPU-bound game where the driver honors it)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break d;
|
||||||
|
}
|
||||||
|
// A refused class must never fail the bridge — walk the ladder down.
|
||||||
|
Err(
|
||||||
|
vk::Result::ERROR_NOT_PERMITTED_KHR
|
||||||
|
| vk::Result::ERROR_INITIALIZATION_FAILED,
|
||||||
|
) if try_priority == Some(vk::QueueGlobalPriorityKHR::REALTIME) => {
|
||||||
|
try_priority = Some(vk::QueueGlobalPriorityKHR::HIGH);
|
||||||
|
}
|
||||||
|
Err(
|
||||||
|
vk::Result::ERROR_NOT_PERMITTED_KHR
|
||||||
|
| vk::Result::ERROR_INITIALIZATION_FAILED,
|
||||||
|
) if try_priority.is_some() => {
|
||||||
|
tracing::debug!(
|
||||||
|
"global-priority queue not permitted — VkBridge at default priority"
|
||||||
|
);
|
||||||
|
try_priority = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(e)
|
||||||
|
.context("vkCreateDevice (external-memory extensions supported?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
||||||
let queue = device.get_device_queue(qf, 0);
|
let queue = device.get_device_queue(qf, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=src/abi.rs");
|
println!("cargo:rerun-if-changed=src/abi.rs");
|
||||||
println!("cargo:rerun-if-changed=src/config.rs");
|
println!("cargo:rerun-if-changed=src/config.rs");
|
||||||
println!("cargo:rerun-if-changed=src/input.rs");
|
println!("cargo:rerun-if-changed=src/input.rs");
|
||||||
println!("cargo:rerun-if-changed=src/client.rs");
|
|
||||||
println!("cargo:rerun-if-changed=src/error.rs");
|
println!("cargo:rerun-if-changed=src/error.rs");
|
||||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ impl PunktfunkConfig {
|
|||||||
u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
||||||
let max_data_per_block =
|
let max_data_per_block =
|
||||||
u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
||||||
|
// The one narrowing here that differs by target width: on 32-bit (armeabi-v7a) an
|
||||||
|
// `as usize` silently truncates a >4 GiB value to a plausible-looking residue that
|
||||||
|
// passes validate() — reject it instead, like every narrowing above.
|
||||||
|
let max_frame_bytes =
|
||||||
|
usize::try_from(self.max_frame_bytes).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
role,
|
role,
|
||||||
phase,
|
phase,
|
||||||
@@ -87,7 +92,7 @@ impl PunktfunkConfig {
|
|||||||
max_data_per_block,
|
max_data_per_block,
|
||||||
},
|
},
|
||||||
shard_payload: self.shard_payload as usize,
|
shard_payload: self.shard_payload as usize,
|
||||||
max_frame_bytes: self.max_frame_bytes as usize,
|
max_frame_bytes,
|
||||||
encrypt: self.encrypt != 0,
|
encrypt: self.encrypt != 0,
|
||||||
key: self.key,
|
key: self.key,
|
||||||
salt: self.salt,
|
salt: self.salt,
|
||||||
@@ -463,23 +468,31 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
|
|||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
|
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
|
||||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
let s = match unsafe { s.as_mut() } {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return PunktfunkStatus::NullPointer as i32,
|
|
||||||
};
|
|
||||||
let cb = s.input_cb;
|
|
||||||
let mut count = 0i32;
|
let mut count = 0i32;
|
||||||
loop {
|
loop {
|
||||||
match s.inner.poll_input() {
|
// Narrow scope: re-derive the handle and pull ONE event, then drop the borrow
|
||||||
Ok(Some(ev)) => {
|
// before dispatching. The callback may legally re-enter `punktfunk_*` on this
|
||||||
if let Some((cb, user)) = cb {
|
// handle (get_stats, send_input, clearing the callback) — with a `&mut` held
|
||||||
cb(&ev as *const InputEvent, user);
|
// across the call that re-entry aliased it (UB under noalias). Re-reading
|
||||||
}
|
// `input_cb` per iteration also makes a mid-drain
|
||||||
count += 1;
|
// `punktfunk_set_input_callback(s, NULL, NULL)` take effect immediately instead
|
||||||
|
// of firing the cleared callback for the queued remainder. (Freeing the session
|
||||||
|
// from inside the callback remains forbidden, as on every entry point.)
|
||||||
|
let (ev, cb) = {
|
||||||
|
let s = match unsafe { s.as_mut() } {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return PunktfunkStatus::NullPointer as i32,
|
||||||
|
};
|
||||||
|
match s.inner.poll_input() {
|
||||||
|
Ok(Some(ev)) => (ev, s.input_cb),
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => return e.status() as i32,
|
||||||
}
|
}
|
||||||
Ok(None) => break,
|
};
|
||||||
Err(e) => return e.status() as i32,
|
if let Some((cb, user)) = cb {
|
||||||
|
cb(&ev as *const InputEvent, user);
|
||||||
}
|
}
|
||||||
|
count += 1;
|
||||||
}
|
}
|
||||||
count
|
count
|
||||||
}));
|
}));
|
||||||
@@ -885,8 +898,8 @@ pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0;
|
|||||||
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
||||||
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
||||||
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
||||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored on
|
||||||
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
/// Linux (UHID) and Windows (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
||||||
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
||||||
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
||||||
@@ -895,8 +908,8 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
|||||||
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||||
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
||||||
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
||||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored on Linux (UHID) and Windows
|
||||||
/// hosts); otherwise the host falls back to X-Box 360.
|
/// (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||||
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||||
@@ -906,10 +919,12 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
|||||||
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||||
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
/// client's back paddles land on native slots. Honored on Linux (UHID `hid-playstation`) and
|
||||||
|
/// Windows (UMDF) hosts; otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
||||||
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||||
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
/// positional layout, gyro/accel, HD rumble. Honored only where available (Linux hosts, UHID
|
||||||
|
/// `hid-nintendo`); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||||
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
||||||
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
|
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
|
||||||
@@ -1914,6 +1929,13 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
|||||||
}
|
}
|
||||||
let AudioPcmState { decoder, pcm } = &mut *state;
|
let AudioPcmState { decoder, pcm } = &mut *state;
|
||||||
let dec = decoder.as_mut().unwrap();
|
let dec = decoder.as_mut().unwrap();
|
||||||
|
// A header-only datagram (DTX silence — a legal wire form) must be SKIPPED, not
|
||||||
|
// decoded: `decode_float` treats an empty payload as a loss and synthesizes a full
|
||||||
|
// 120 ms of concealment for a ~5 ms slot, growing the playout ring without bound.
|
||||||
|
// Mirrors the host mic pump's guard; the sink underruns to silence on its own.
|
||||||
|
if pkt.data.is_empty() {
|
||||||
|
return PunktfunkStatus::NoFrame;
|
||||||
|
}
|
||||||
// `decode_float` divides the output buffer length by the channel count to get the
|
// `decode_float` divides the output buffer length by the channel count to get the
|
||||||
// per-channel capacity; an empty payload requests packet-loss concealment.
|
// per-channel capacity; an empty payload requests packet-loss concealment.
|
||||||
match dec.decode_float(&pkt.data, pcm, false) {
|
match dec.decode_float(&pkt.data, pcm, false) {
|
||||||
@@ -2964,7 +2986,14 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
|||||||
unsafe { *out = out_ev };
|
unsafe { *out = out_ev };
|
||||||
PunktfunkStatus::Ok
|
PunktfunkStatus::Ok
|
||||||
}
|
}
|
||||||
Err(e) => e.status(),
|
Err(e) => {
|
||||||
|
// Release the parked payload once the embedder polls past it: clipboard
|
||||||
|
// traffic is sporadic, so without this a one-off 50 MiB paste stays resident
|
||||||
|
// for the rest of the session (there is no other release entry point). The
|
||||||
|
// borrow contract already says `out` data is valid only until the next call.
|
||||||
|
*c.last_clip.lock().unwrap() = None;
|
||||||
|
e.status()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3054,6 +3083,35 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The **live** host↔client wall-clock offset (nanoseconds, host minus client): the
|
||||||
|
/// connect-time estimate of [`punktfunk_connection_clock_offset_ns`], updated by every applied
|
||||||
|
/// mid-stream clock re-sync. Ongoing latency math (per-frame `received − pts` splits, the
|
||||||
|
/// glass-to-glass meter) must use this one — after a wall-clock step/slew the frozen
|
||||||
|
/// connect-time value reads tens of milliseconds wrong for the rest of the session, while the
|
||||||
|
/// core itself has already re-synced. Same clock contract as the connect-time getter.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
offset_ns: *mut i64,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if !offset_ns.is_null() {
|
||||||
|
*offset_ns = c.inner.clock_offset_now_ns();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
||||||
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
||||||
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
||||||
@@ -3193,6 +3251,11 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
|||||||
out: *mut u64,
|
out: *mut u64,
|
||||||
) -> PunktfunkStatus {
|
) -> PunktfunkStatus {
|
||||||
guard(|| {
|
guard(|| {
|
||||||
|
// The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
|
||||||
|
// check, so an embedder that skips the status never reads an uninitialized slot.
|
||||||
|
if !out.is_null() {
|
||||||
|
unsafe { *out = 0 };
|
||||||
|
}
|
||||||
let c = match unsafe { c.as_ref() } {
|
let c = match unsafe { c.as_ref() } {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return PunktfunkStatus::NullPointer,
|
None => return PunktfunkStatus::NullPointer,
|
||||||
@@ -3250,6 +3313,11 @@ pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
|
|||||||
out: *mut bool,
|
out: *mut bool,
|
||||||
) -> PunktfunkStatus {
|
) -> PunktfunkStatus {
|
||||||
guard(|| {
|
guard(|| {
|
||||||
|
// The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
|
||||||
|
// check: an uninitialized byte is not even a valid C++/Swift bool to read.
|
||||||
|
if !out.is_null() {
|
||||||
|
unsafe { *out = false };
|
||||||
|
}
|
||||||
let c = match unsafe { c.as_ref() } {
|
let c = match unsafe { c.as_ref() } {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return PunktfunkStatus::NullPointer,
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
|||||||
@@ -425,6 +425,12 @@ impl NativeClient {
|
|||||||
Ok(Ok(t)) => t,
|
Ok(Ok(t)) => t,
|
||||||
Ok(Err(e)) => return Err(e),
|
Ok(Err(e)) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
// A connect we already reported as failed must not leave a lingering host
|
||||||
|
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||||
|
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||||
|
// instead of holding the session (and its virtual display) for a reconnect
|
||||||
|
// that will never come.
|
||||||
|
quit.store(true, Ordering::SeqCst);
|
||||||
shutdown.store(true, Ordering::SeqCst);
|
shutdown.store(true, Ordering::SeqCst);
|
||||||
return Err(PunktfunkError::Timeout);
|
return Err(PunktfunkError::Timeout);
|
||||||
}
|
}
|
||||||
@@ -759,7 +765,9 @@ impl NativeClient {
|
|||||||
0.0
|
0.0
|
||||||
} as f32;
|
} as f32;
|
||||||
// Host-side drop: what the send buffer couldn't even accept (the host-side ceiling).
|
// Host-side drop: what the send buffer couldn't even accept (the host-side ceiling).
|
||||||
let offered_wire = p.host_wire_packets + p.host_send_dropped;
|
// Saturating: both counters arrive verbatim off the wire (same discipline as the
|
||||||
|
// saturating_sub/mul above — a hostile sum must not overflow-panic a debug build).
|
||||||
|
let offered_wire = p.host_wire_packets.saturating_add(p.host_send_dropped);
|
||||||
let host_drop_pct = if offered_wire > 0 {
|
let host_drop_pct = if offered_wire > 0 {
|
||||||
p.host_send_dropped as f64 / offered_wire as f64 * 100.0
|
p.host_send_dropped as f64 / offered_wire as f64 * 100.0
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
|||||||
|
//! Control task: the handshake stream stays open for mid-stream renegotiation + speed tests.
|
||||||
|
//! Outbound requests (mode switch, probe) and inbound replies (Reconfigured, ProbeResult) are
|
||||||
|
//! multiplexed with `select!`; a single outbound channel (`ctrl_rx`) keeps one writer so the
|
||||||
|
//! two `&mut ctrl_send` borrows don't collide across branches.
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) struct ControlTask {
|
||||||
|
pub(super) ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
||||||
|
pub(super) ctrl_send: quinn::SendStream,
|
||||||
|
pub(super) ctrl_recv: io::MsgReader,
|
||||||
|
/// `None` = no connect-time skew handshake (old host) — clock re-sync stays off.
|
||||||
|
pub(super) clock_rtt_ns: Option<u64>,
|
||||||
|
pub(super) mode_slot: Arc<Mutex<Mode>>,
|
||||||
|
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||||
|
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||||
|
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||||
|
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||||
|
pub(super) clock_gen: Arc<AtomicU32>,
|
||||||
|
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
|
||||||
|
/// clipboard task uses for fetch data.
|
||||||
|
pub(super) clip_event_tx: std::sync::mpsc::SyncSender<ClipEventCore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlTask {
|
||||||
|
pub(super) async fn run(self) {
|
||||||
|
let ControlTask {
|
||||||
|
mut ctrl_rx,
|
||||||
|
mut ctrl_send,
|
||||||
|
mut ctrl_recv,
|
||||||
|
clock_rtt_ns,
|
||||||
|
mode_slot,
|
||||||
|
probe,
|
||||||
|
bitrate_ack,
|
||||||
|
clock_offset,
|
||||||
|
clock_gen,
|
||||||
|
clip_event_tx,
|
||||||
|
} = self;
|
||||||
|
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||||
|
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||||
|
// its first no-op clock flush). Echoes interleave with the other control replies in
|
||||||
|
// the read arm below; only when the host answered the connect-time handshake — an
|
||||||
|
// old host would just eat the probes.
|
||||||
|
let mut resync = ClockResync::new();
|
||||||
|
let mut resync_tick = tokio::time::interval_at(
|
||||||
|
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
|
||||||
|
CLOCK_RESYNC_INTERVAL,
|
||||||
|
);
|
||||||
|
resync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
req = ctrl_rx.recv() => {
|
||||||
|
let Some(req) = req else { break }; // client dropped
|
||||||
|
let bytes = match req {
|
||||||
|
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||||
|
CtrlRequest::Probe(p) => p.encode(),
|
||||||
|
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||||
|
CtrlRequest::Rfi(r) => r.encode(),
|
||||||
|
CtrlRequest::Loss(r) => r.encode(),
|
||||||
|
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||||
|
CtrlRequest::ClockResync => {
|
||||||
|
if clock_rtt_ns.is_none() {
|
||||||
|
continue; // no connect-time handshake — host can't answer
|
||||||
|
}
|
||||||
|
resync.begin(wall_clock_ns()).encode()
|
||||||
|
}
|
||||||
|
CtrlRequest::ClipControl(c) => c.encode(),
|
||||||
|
CtrlRequest::ClipOffer(o) => o.encode(),
|
||||||
|
};
|
||||||
|
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
|
||||||
|
let probe = resync.begin(wall_clock_ns());
|
||||||
|
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = ctrl_recv.read_msg() => {
|
||||||
|
let Ok(msg) = msg else { break }; // stream closed
|
||||||
|
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||||
|
if ack.accepted {
|
||||||
|
*mode_slot.lock().unwrap() = ack.mode;
|
||||||
|
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
||||||
|
}
|
||||||
|
} else if let Ok(result) = ProbeResult::decode(&msg) {
|
||||||
|
let mut p = probe.lock().unwrap();
|
||||||
|
// Freeze the delivered figures now (the burst is done), before resumed
|
||||||
|
// video can inflate the packet counters.
|
||||||
|
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
|
||||||
|
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
|
||||||
|
p.delivered_packets = p.rx_packets_now.saturating_sub(base_p);
|
||||||
|
p.delivered_bytes = p.rx_bytes_now.saturating_sub(base_b);
|
||||||
|
p.host_goodput_bytes = result.bytes_sent;
|
||||||
|
p.host_au = result.packets_sent;
|
||||||
|
p.host_wire_packets = result.wire_packets_sent;
|
||||||
|
p.host_send_dropped = result.send_dropped;
|
||||||
|
p.host_duration_ms = result.duration_ms;
|
||||||
|
p.done = true;
|
||||||
|
p.active = false; // burst over — the pump stops mirroring counters
|
||||||
|
tracing::info!(
|
||||||
|
host_goodput_bytes = result.bytes_sent,
|
||||||
|
wire_packets_sent = result.wire_packets_sent,
|
||||||
|
send_dropped = result.send_dropped,
|
||||||
|
duration_ms = result.duration_ms,
|
||||||
|
delivered_packets = p.delivered_packets,
|
||||||
|
"speed-test probe result"
|
||||||
|
);
|
||||||
|
} else if let Ok(ack) = BitrateChanged::decode(&msg) {
|
||||||
|
// Adaptive bitrate: the host's clamp is authoritative — park it for
|
||||||
|
// the pump's controller (which also reads any ack as "this host
|
||||||
|
// renegotiates", arming further steps).
|
||||||
|
tracing::info!(
|
||||||
|
kbps = ack.bitrate_kbps,
|
||||||
|
"host re-targeted encoder bitrate"
|
||||||
|
);
|
||||||
|
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||||
|
} else if let Ok(echo) = ClockEcho::decode(&msg) {
|
||||||
|
match resync.on_echo(&echo, wall_clock_ns()) {
|
||||||
|
ResyncStep::Probe(p) => {
|
||||||
|
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||||
|
// Never let a congested window bias the offset (frames read
|
||||||
|
// late exactly then) — keep the old estimate and let the next
|
||||||
|
// periodic batch try again.
|
||||||
|
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
|
||||||
|
// info, not debug: ≤1/min, and it is THE forensic
|
||||||
|
// trail for a stale-offset (stepped/slewed wall clock)
|
||||||
|
// latency plateau — the 2026-07 two-pair investigation
|
||||||
|
// had to reconstruct this blind.
|
||||||
|
tracing::info!(
|
||||||
|
offset_ns,
|
||||||
|
rtt_us = rtt_ns / 1000,
|
||||||
|
"mid-stream clock re-sync applied"
|
||||||
|
);
|
||||||
|
clock_offset.store(offset_ns, Ordering::Relaxed);
|
||||||
|
clock_gen.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
rtt_us = rtt_ns / 1000,
|
||||||
|
"clock re-sync batch discarded — RTT above the \
|
||||||
|
connect-time baseline (congested window)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResyncStep::Idle => {}
|
||||||
|
}
|
||||||
|
} else if let Ok(state) = ClipState::decode(&msg) {
|
||||||
|
// Host ack / policy / backend update for the toggle UI (try_send: a
|
||||||
|
// lagging embedder drops the newest — a stale toggle heals on the next).
|
||||||
|
let _ = clip_event_tx.try_send(ClipEventCore::State {
|
||||||
|
enabled: state.enabled,
|
||||||
|
policy: state.policy,
|
||||||
|
reason: state.reason,
|
||||||
|
});
|
||||||
|
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||||
|
// The host copied something: surface the lazy format list; the embedder
|
||||||
|
// fetches only if a local app pastes.
|
||||||
|
let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer {
|
||||||
|
seq: offer.seq,
|
||||||
|
kinds: offer.kinds,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
tag = ?msg.first(),
|
||||||
|
len = msg.len(),
|
||||||
|
"unknown control message — ignoring"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
//! The blocking data-plane pump: poll the session for access units, run the adaptive-FEC
|
||||||
|
//! loss reports, the ABR controller + startup capacity probe, the jump-to-live detectors,
|
||||||
|
//! and the standing-latency bleed, and hand frames to the embedder.
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Data-plane pump on a blocking thread: poll the session, hand frames to the embedder.
|
||||||
|
/// try_send drops the newest frame when the embedder lags (freshness over completeness).
|
||||||
|
/// Speed-test filler ([`FLAG_PROBE`]) is folded into the probe accumulator instead of the
|
||||||
|
/// decoder queue — it isn't video.
|
||||||
|
pub(super) struct DataPump {
|
||||||
|
pub(super) session: Session,
|
||||||
|
pub(super) frames: Arc<FrameChannel>,
|
||||||
|
pub(super) ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||||
|
pub(super) shutdown: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||||
|
pub(super) hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||||
|
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||||
|
pub(super) clock_gen: Arc<AtomicU32>,
|
||||||
|
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||||
|
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||||
|
/// The embedder's REQUESTED rate (0 = Automatic — the only case the ABR arms).
|
||||||
|
pub(super) bitrate_kbps: u32,
|
||||||
|
/// The rate the host actually configured (echoed in Welcome).
|
||||||
|
pub(super) resolved_bitrate_kbps: u32,
|
||||||
|
pub(super) negotiated_codec: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataPump {
|
||||||
|
pub(super) fn run(self) {
|
||||||
|
let DataPump {
|
||||||
|
mut session,
|
||||||
|
frames,
|
||||||
|
ctrl_tx,
|
||||||
|
shutdown: pump_shutdown,
|
||||||
|
probe: pump_probe,
|
||||||
|
hot_tids: pump_hot_tids,
|
||||||
|
clock_offset: pump_clock_offset,
|
||||||
|
clock_gen: pump_clock_gen,
|
||||||
|
decode_lat: pump_decode_lat,
|
||||||
|
frames_dropped,
|
||||||
|
fec_recovered,
|
||||||
|
bitrate_ack,
|
||||||
|
bitrate_kbps,
|
||||||
|
resolved_bitrate_kbps,
|
||||||
|
negotiated_codec,
|
||||||
|
} = self;
|
||||||
|
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||||
|
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||||
|
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
|
||||||
|
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
|
||||||
|
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||||
|
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
|
let mut last_report = Instant::now();
|
||||||
|
let (
|
||||||
|
mut last_recovered,
|
||||||
|
mut last_late,
|
||||||
|
mut last_received,
|
||||||
|
mut last_dropped,
|
||||||
|
mut last_bytes,
|
||||||
|
) = (0u64, 0u64, 0u64, 0u64, 0u64);
|
||||||
|
// PUNKTFUNK_PERF: per-window pump observability — the Session's receive stage split
|
||||||
|
// (recv / decrypt / reassemble+FEC, see `Session::take_pump_perf`) and completed-AU
|
||||||
|
// inter-arrival jitter. Smoothness has no metric otherwise: jump-to-live counters only
|
||||||
|
// fire after the stream is already seconds behind.
|
||||||
|
let pump_perf_on = std::env::var("PUNKTFUNK_PERF").is_ok_and(|v| v != "0");
|
||||||
|
let mut arrivals_us: Vec<u32> = Vec::new();
|
||||||
|
let mut last_arrival: Option<Instant> = None;
|
||||||
|
// Adaptive bitrate (see `crate::abr`): armed only when the embedder asked for Automatic
|
||||||
|
// (`bitrate_kbps == 0`) and the host echoed the rate it actually configured (an old host
|
||||||
|
// echoes 0 → controller stays permanently off). Fed once per report window with the same
|
||||||
|
// deltas the LossReport uses, plus the window's mean skew-corrected one-way delay, the
|
||||||
|
// actual delivered throughput (climb gate + proven-throughput mark), and whether a
|
||||||
|
// jump-to-live flush fired.
|
||||||
|
// PyroWave sessions PIN their rate (§4.6): AIMD descent turns wavelets to mush well
|
||||||
|
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
|
||||||
|
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
|
||||||
|
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
|
||||||
|
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
|
||||||
|
resolved_bitrate_kbps
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
});
|
||||||
|
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
|
||||||
|
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
|
||||||
|
// could NEVER climb out of. One speed-test burst shortly after the stream settles
|
||||||
|
// measures what the link actually delivers; ×0.7 (headroom for FEC overhead + variance)
|
||||||
|
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||||
|
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||||
|
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||||
|
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||||
|
const CAPACITY_PROBE_MS: u32 = 800;
|
||||||
|
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||||
|
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
|
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
|
||||||
|
&& !rate_pinned
|
||||||
|
&& resolved_bitrate_kbps > 0
|
||||||
|
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
|
||||||
|
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
|
||||||
|
let mut capacity_probe_deadline: Option<Instant> = None;
|
||||||
|
// Edge detector + watchdog for a probe of EITHER origin (the startup capacity probe or an
|
||||||
|
// embedder speed test via `NativeClient::request_probe`). The startup path had both built
|
||||||
|
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||||
|
// finished one left the ABR window anchored before the burst.
|
||||||
|
let mut was_probing = false;
|
||||||
|
let mut probe_watchdog: Option<Instant> = None;
|
||||||
|
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||||
|
let mut flush_in_window = false;
|
||||||
|
// Jump-to-live state (see the guard in the loop below): when the clock-based over-bound
|
||||||
|
// run began (`stale_since`, armed only when the skew handshake succeeded so the clocks
|
||||||
|
// are comparable), when the clock-free non-draining-queue run began (`standing_since`),
|
||||||
|
// and the last-jump instant for the shared cooldown. Wall-clock runs (T1.4), not frame
|
||||||
|
// counts — the detectors' sensitivity must not scale with fps or repeat cadence.
|
||||||
|
let mut stale_since: Option<Instant> = None;
|
||||||
|
let mut standing_since: Option<Instant> = None;
|
||||||
|
let mut last_flush: Option<Instant> = None;
|
||||||
|
// Clock-detector health: consecutive clock-triggered flushes that found no local backlog
|
||||||
|
// (see NOOP_FLUSH_DATAGRAMS). Reaching NOOP_CLOCK_FLUSHES_TO_DISARM turns the clock-based
|
||||||
|
// detector off (a clock step / upstream queue it can't fix) — until a mid-stream clock
|
||||||
|
// re-sync lands and re-arms it (`pump_clock_gen` below). The FIRST no-op flush also asks
|
||||||
|
// the control task for an immediate re-sync (via the report tick): the flush finding no
|
||||||
|
// local backlog IS the "the wall clock stepped under me" signal.
|
||||||
|
let mut noop_clock_flushes: u32 = 0;
|
||||||
|
let mut clock_detector_armed = true;
|
||||||
|
let mut resync_wanted = false;
|
||||||
|
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||||
|
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
|
||||||
|
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
||||||
|
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
|
||||||
|
// backlog, or a stale clock offset after a wall-clock step, either of which otherwise
|
||||||
|
// reads as permanent extra "network" latency for the rest of the session.
|
||||||
|
let mut standing_lat = StandingLatency::new();
|
||||||
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||||
|
// The live host↔client offset: re-loaded every iteration so an applied mid-stream
|
||||||
|
// re-sync takes effect on the very next frame's latency math.
|
||||||
|
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
|
||||||
|
// An applied re-sync invalidates the staleness run measured under the OLD offset:
|
||||||
|
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
|
||||||
|
let gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||||
|
if gen != seen_clock_gen {
|
||||||
|
seen_clock_gen = gen;
|
||||||
|
stale_since = None;
|
||||||
|
noop_clock_flushes = 0;
|
||||||
|
// Every OWD reading shifted with the offset — the standing-latency floor and
|
||||||
|
// any elevation measured under the old one are meaningless now. If a stale
|
||||||
|
// offset WAS the elevation, this is also the moment it gets fixed.
|
||||||
|
standing_lat.rebase();
|
||||||
|
if !clock_detector_armed {
|
||||||
|
clock_detector_armed = true;
|
||||||
|
tracing::info!("clock re-sync applied — clock-based jump-to-live re-armed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
||||||
|
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
||||||
|
// measurement. Updated every iteration (not just on a produced frame) so they stay current
|
||||||
|
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||||
|
let st = session.stats();
|
||||||
|
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
||||||
|
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
|
||||||
|
let probe_active = {
|
||||||
|
let mut p = pump_probe.lock().unwrap();
|
||||||
|
if p.active && !p.done {
|
||||||
|
p.rx_packets_now = st.packets_received;
|
||||||
|
p.rx_bytes_now = st.bytes_received;
|
||||||
|
p.base_packets.get_or_insert(st.packets_received);
|
||||||
|
p.base_bytes.get_or_insert(st.bytes_received);
|
||||||
|
}
|
||||||
|
p.active && !p.done
|
||||||
|
};
|
||||||
|
// A probe just ended (either kind): rebase EVERY window anchor past the burst. Its
|
||||||
|
// FLAG_PROBE filler landed in `bytes_received`/`packets_received` (session.rs counts
|
||||||
|
// every accepted datagram) but never reached the decoder, and the report tick was
|
||||||
|
// suppressed for the whole burst, so `last_*` still points before it. Without this the
|
||||||
|
// first post-burst window reads the burst rate as `actual_kbps` and poisons the ABR's
|
||||||
|
// monotone proven-throughput high-water mark — which never decays — and divides the
|
||||||
|
// window's loss by a packet count inflated with filler.
|
||||||
|
if was_probing && !probe_active {
|
||||||
|
last_recovered = st.fec_recovered_shards;
|
||||||
|
last_late = st.fec_late_shards;
|
||||||
|
last_received = st.packets_received;
|
||||||
|
last_dropped = st.frames_dropped;
|
||||||
|
last_bytes = st.bytes_received;
|
||||||
|
last_report = Instant::now();
|
||||||
|
}
|
||||||
|
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||||
|
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||||
|
// cannot latch `active` forever and suppress the report tick for the whole session.
|
||||||
|
if !was_probing && probe_active {
|
||||||
|
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
|
||||||
|
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
|
||||||
|
}
|
||||||
|
if !probe_active {
|
||||||
|
probe_watchdog = None;
|
||||||
|
} else if let Some(deadline) = probe_watchdog {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
probe_watchdog = None;
|
||||||
|
pump_probe.lock().unwrap().active = false;
|
||||||
|
tracing::warn!(
|
||||||
|
"speed-test probe unanswered — clearing it so loss reports and ABR resume"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
was_probing = probe_active;
|
||||||
|
// Fire the startup link-capacity probe once the stream has settled (see the constants
|
||||||
|
// above), and fold its measurement into the ABR ceiling when the result lands.
|
||||||
|
// Never steal the slot from an embedder speed test in flight: there is one `ProbeState`
|
||||||
|
// and no correlation id, so a clobber both wrecks the user's "Test connection" figure
|
||||||
|
// (its base counters get re-snapshotted mid-burst against the full-burst denominator)
|
||||||
|
// and mis-scales our own ceiling. Retry once it finishes.
|
||||||
|
if capacity_probe_at.is_some_and(|at| Instant::now() >= at) && probe_active {
|
||||||
|
capacity_probe_at = Some(Instant::now() + CAPACITY_PROBE_DELAY);
|
||||||
|
} else if capacity_probe_at.is_some_and(|at| Instant::now() >= at) {
|
||||||
|
capacity_probe_at = None;
|
||||||
|
*pump_probe.lock().unwrap() = ProbeState {
|
||||||
|
active: true,
|
||||||
|
duration_ms: CAPACITY_PROBE_MS,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if ctrl_tx
|
||||||
|
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||||
|
target_kbps: CAPACITY_PROBE_KBPS,
|
||||||
|
duration_ms: CAPACITY_PROBE_MS,
|
||||||
|
}))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||||
|
tracing::info!(
|
||||||
|
target_kbps = CAPACITY_PROBE_KBPS,
|
||||||
|
duration_ms = CAPACITY_PROBE_MS,
|
||||||
|
"adaptive bitrate: startup link-capacity probe"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(deadline) = capacity_probe_deadline {
|
||||||
|
let mut p = pump_probe.lock().unwrap();
|
||||||
|
if p.done {
|
||||||
|
capacity_probe_deadline = None;
|
||||||
|
// An all-zero reply is a decline (old host / probe-less build) — keep the
|
||||||
|
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
|
||||||
|
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
|
||||||
|
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
|
||||||
|
/ p.host_duration_ms.max(1) as u64)
|
||||||
|
as u32;
|
||||||
|
let ceiling = delivered_kbps.saturating_mul(7) / 10;
|
||||||
|
abr.set_ceiling(ceiling);
|
||||||
|
tracing::info!(
|
||||||
|
delivered_kbps,
|
||||||
|
ceiling_kbps = ceiling,
|
||||||
|
"adaptive bitrate: link-capacity probe done — climb ceiling set"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
|
||||||
|
// the decoder — rebase the ABR window's byte counter past it, or the next
|
||||||
|
// window's "actual throughput" reads as the burst rate and poisons the
|
||||||
|
// controller's proven-throughput high-water mark with the LINK rate.
|
||||||
|
last_bytes = st.bytes_received;
|
||||||
|
} else if Instant::now() >= deadline {
|
||||||
|
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||||
|
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||||
|
p.active = false;
|
||||||
|
capacity_probe_deadline = None;
|
||||||
|
tracing::info!(
|
||||||
|
"adaptive bitrate: capacity probe timed out (old host?) — keeping negotiated ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||||
|
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
||||||
|
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
||||||
|
if resync_wanted {
|
||||||
|
resync_wanted = false;
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||||
|
}
|
||||||
|
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||||
|
let loss_ppm = window_loss_ppm(
|
||||||
|
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||||
|
st.fec_late_shards.wrapping_sub(last_late),
|
||||||
|
st.packets_received.wrapping_sub(last_received),
|
||||||
|
window_dropped,
|
||||||
|
);
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||||
|
// Standing-latency bleed: close the detector's window with this report's loss
|
||||||
|
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||||
|
// from a stepped wall clock produces exactly this signature and the applied
|
||||||
|
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
|
||||||
|
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
|
||||||
|
// loud disarm (the path latency itself changed; nothing local fixes that).
|
||||||
|
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
|
||||||
|
StandingLatAction::None => {}
|
||||||
|
StandingLatAction::Resync { above_ms } => {
|
||||||
|
tracing::info!(
|
||||||
|
above_ms,
|
||||||
|
"standing latency above the session floor with zero loss — \
|
||||||
|
requesting a clock re-sync first (a stale offset reads exactly \
|
||||||
|
like this)"
|
||||||
|
);
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||||
|
}
|
||||||
|
StandingLatAction::Bleed { above_ms } => {
|
||||||
|
// Shares the jump-to-live cooldown: an unexecuted bleed simply re-arms
|
||||||
|
// over the next windows (the detector's run rebuilds).
|
||||||
|
if last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN) {
|
||||||
|
last_flush = Some(Instant::now());
|
||||||
|
// Deliberately NOT `flush_in_window = true`: that flag is the ABR's
|
||||||
|
// SEVERE verdict (an immediate ×0.7 back-off), and the bleed fires
|
||||||
|
// only after ~6 provably loss-free windows with a sub-25ms elevation
|
||||||
|
// the controller itself scores as fine. The bleed's effect reaches
|
||||||
|
// the ABR through the window's own honest signals (OWD/loss/decode);
|
||||||
|
// the flag stays exclusive to the jump-to-live path below.
|
||||||
|
let flushed = session.flush_backlog().unwrap_or(0);
|
||||||
|
let dropped = frames.clear();
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||||
|
standing_lat.bled();
|
||||||
|
tracing::warn!(
|
||||||
|
above_ms,
|
||||||
|
flushed_datagrams = flushed,
|
||||||
|
dropped_frames = dropped,
|
||||||
|
"standing latency survived a clock re-sync — bled the local \
|
||||||
|
backlog (flush + keyframe)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StandingLatAction::Disarm { above_ms } => {
|
||||||
|
tracing::warn!(
|
||||||
|
above_ms,
|
||||||
|
"standing latency persists after a re-sync and every bleed — not \
|
||||||
|
local, not clock; the path latency changed. Leaving it be \
|
||||||
|
(reconnect re-baselines)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
||||||
|
// feed the controller this window's congestion signals; a decision becomes a
|
||||||
|
// SetBitrate on the control stream.
|
||||||
|
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||||
|
abr.on_ack(acked);
|
||||||
|
}
|
||||||
|
let owd_mean_us =
|
||||||
|
(owd_frames > 0).then(|| (owd_sum_ns / owd_frames as i128 / 1000) as i64);
|
||||||
|
(owd_sum_ns, owd_frames) = (0, 0);
|
||||||
|
// Drain the embedder's decode-latency window (always, so it stays bounded even when
|
||||||
|
// the controller is disabled) → the mean feeds the decode signal; `None` when the
|
||||||
|
// embedder reported nothing this window (old embedder / no decoded frames).
|
||||||
|
let decode_mean_us = {
|
||||||
|
let mut acc = pump_decode_lat.lock().unwrap();
|
||||||
|
let (sum, count) = (acc.sum_us, acc.count);
|
||||||
|
*acc = DecodeLatAcc::default();
|
||||||
|
(count > 0).then(|| (sum / count as u64) as i64)
|
||||||
|
};
|
||||||
|
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||||
|
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||||
|
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||||
|
// semantics (both compare against targets with their own headroom).
|
||||||
|
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||||
|
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
|
||||||
|
/ window_ms) as u32;
|
||||||
|
if let Some(kbps) = abr.on_window(
|
||||||
|
Instant::now(),
|
||||||
|
window_dropped,
|
||||||
|
loss_ppm,
|
||||||
|
owd_mean_us,
|
||||||
|
decode_mean_us,
|
||||||
|
actual_kbps,
|
||||||
|
flush_in_window,
|
||||||
|
) {
|
||||||
|
// Log the window's signals alongside the decision so an on-glass session can
|
||||||
|
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
|
||||||
|
// loss/OWD flat) from a network-driven one.
|
||||||
|
tracing::info!(
|
||||||
|
kbps,
|
||||||
|
loss_ppm,
|
||||||
|
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
||||||
|
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
||||||
|
actual_kbps,
|
||||||
|
flushed = flush_in_window,
|
||||||
|
"adaptive bitrate: requesting encoder re-target"
|
||||||
|
);
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||||
|
}
|
||||||
|
flush_in_window = false;
|
||||||
|
last_report = Instant::now();
|
||||||
|
last_recovered = st.fec_recovered_shards;
|
||||||
|
last_late = st.fec_late_shards;
|
||||||
|
last_received = st.packets_received;
|
||||||
|
last_dropped = st.frames_dropped;
|
||||||
|
last_bytes = st.bytes_received;
|
||||||
|
if pump_perf_on {
|
||||||
|
if let Some(p) = session.take_pump_perf() {
|
||||||
|
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||||
|
tracing::info!(
|
||||||
|
recv_ms = p.recv_ns / 1_000_000,
|
||||||
|
decrypt_ms = p.decrypt_ns / 1_000_000,
|
||||||
|
reasm_ms = p.reasm_ns / 1_000_000,
|
||||||
|
packets = p.packets,
|
||||||
|
batches = p.batches,
|
||||||
|
pkts_per_batch = p.packets.checked_div(p.batches).unwrap_or(0),
|
||||||
|
decrypt_ns_pkt = per_pkt_ns(p.decrypt_ns),
|
||||||
|
reasm_ns_pkt = per_pkt_ns(p.reasm_ns),
|
||||||
|
"pump stage split (window)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Inter-arrival jitter over the window's completed AUs. `late` counts gaps
|
||||||
|
// over 2× the window median — the "a frame arrived visibly off-beat" tally.
|
||||||
|
if arrivals_us.len() >= 8 {
|
||||||
|
arrivals_us.sort_unstable();
|
||||||
|
let pct = |q: usize| arrivals_us[(arrivals_us.len() - 1) * q / 100];
|
||||||
|
let (p50, p95) = (pct(50), pct(95));
|
||||||
|
let late = arrivals_us.iter().filter(|&&d| d > p50 * 2).count();
|
||||||
|
tracing::info!(
|
||||||
|
frames = arrivals_us.len() + 1,
|
||||||
|
arrival_p50_us = p50,
|
||||||
|
arrival_p95_us = p95,
|
||||||
|
arrival_max_us = arrivals_us.last().copied().unwrap_or(0),
|
||||||
|
late,
|
||||||
|
"frame inter-arrival jitter (window)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
arrivals_us.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match session.poll_frame() {
|
||||||
|
Ok(frame) => {
|
||||||
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
|
continue; // speed-test filler, not video — measured via the counters above
|
||||||
|
}
|
||||||
|
if pump_perf_on {
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some(prev) = last_arrival.replace(now) {
|
||||||
|
// 4096 ≈ 17 s at 240 fps — a stuck window can't grow it unbounded.
|
||||||
|
if arrivals_us.len() < 4096 {
|
||||||
|
arrivals_us
|
||||||
|
.push((now - prev).as_micros().min(u32::MAX as u128) as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Jump-to-live guard. A standing receive/hand-off queue never drains by itself —
|
||||||
|
// the pump consumes strictly in order at the arrival rate, so once behind, the
|
||||||
|
// stream stays behind for good (observed live: stuck 6–7 s). Pre-decode AUs are
|
||||||
|
// reference-chained (infinite GOP), so we can NOT drop a frame mid-stream to catch
|
||||||
|
// up; the only safe recovery is to discard the whole backlog and re-anchor decode
|
||||||
|
// on a fresh keyframe. Two independent "we're behind" signals arm it, both gated by
|
||||||
|
// FLUSH_COOLDOWN, both suspended during a speed test (the probe MEASURES a saturated
|
||||||
|
// queue; flushing would corrupt its counters):
|
||||||
|
// * clock-based — completed frames sit > FLUSH_LATENCY behind the skew-corrected
|
||||||
|
// capture clock continuously for FLUSH_AFTER. Needs the skew handshake, and
|
||||||
|
// also catches kernel/reassembler backlog the hand-off queue hasn't reached yet.
|
||||||
|
// * clock-free — the pre-decode hand-off queue stopped draining: its depth stayed
|
||||||
|
// ≥ QUEUE_HIGH (never falling to QUEUE_LOW, still high at the trip) for
|
||||||
|
// STANDING_TIME. Works with no handshake / a same-clock session (where the
|
||||||
|
// clock path is disarmed), and is the direct signal that the embedder can't
|
||||||
|
// keep up. A transient Wi-Fi clump drains within ~100 ms and never trips it.
|
||||||
|
if probe_active {
|
||||||
|
// Keep both detectors disarmed across a speed test so its (deliberately)
|
||||||
|
// saturated queue doesn't leave a primed run that fires the moment it ends.
|
||||||
|
stale_since = None;
|
||||||
|
standing_since = None;
|
||||||
|
} else {
|
||||||
|
let lat_ns = if clock_offset_ns != 0 {
|
||||||
|
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
// Feed the adaptive-bitrate controller's OWD window (mean capture→received
|
||||||
|
// delay): rising delay under zero loss is queue growth — the pre-loss
|
||||||
|
// congestion signal. Only meaningful with a clock handshake.
|
||||||
|
if clock_offset_ns != 0 && lat_ns > 0 {
|
||||||
|
owd_sum_ns += lat_ns;
|
||||||
|
owd_frames += 1;
|
||||||
|
// The standing-latency detector rides the same signal, but off the
|
||||||
|
// window MINIMUM (robust against jitter/burst spikes — a standing
|
||||||
|
// state elevates the floor itself). Same 10 s plausibility clamp as
|
||||||
|
// the hn stats use.
|
||||||
|
if lat_ns < 10_000_000_000 {
|
||||||
|
standing_lat.note_frame(lat_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clock_detector_armed
|
||||||
|
&& clock_offset_ns != 0
|
||||||
|
&& lat_ns > FLUSH_LATENCY.as_nanos() as i128
|
||||||
|
{
|
||||||
|
stale_since.get_or_insert_with(Instant::now);
|
||||||
|
} else {
|
||||||
|
stale_since = None;
|
||||||
|
}
|
||||||
|
let depth = frames.depth();
|
||||||
|
if depth >= QUEUE_HIGH {
|
||||||
|
standing_since.get_or_insert_with(Instant::now);
|
||||||
|
} else if depth <= QUEUE_LOW {
|
||||||
|
standing_since = None;
|
||||||
|
}
|
||||||
|
// The queue trip additionally requires the depth to still be high NOW, so
|
||||||
|
// a run that started ≥ high but is hovering in the hysteresis band (a
|
||||||
|
// clump mid-drain) never fires on elapsed time alone.
|
||||||
|
let clock_behind = stale_since.is_some_and(|t| t.elapsed() >= FLUSH_AFTER);
|
||||||
|
let queue_behind = depth >= QUEUE_HIGH
|
||||||
|
&& standing_since.is_some_and(|t| t.elapsed() >= STANDING_TIME);
|
||||||
|
if (clock_behind || queue_behind)
|
||||||
|
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
|
||||||
|
{
|
||||||
|
stale_since = None;
|
||||||
|
standing_since = None;
|
||||||
|
last_flush = Some(Instant::now());
|
||||||
|
flush_in_window = true; // strongest "link can't hold the rate" signal
|
||||||
|
let flushed = session.flush_backlog().unwrap_or(0);
|
||||||
|
let dropped = frames.clear();
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||||
|
tracing::warn!(
|
||||||
|
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
|
||||||
|
queue_depth = depth,
|
||||||
|
flushed_datagrams = flushed,
|
||||||
|
dropped_frames = dropped,
|
||||||
|
"receive backlog stopped draining — jumped to live (flush + keyframe)"
|
||||||
|
);
|
||||||
|
// Clock-detector health check: a clock-only trigger whose flush found
|
||||||
|
// no local backlog is a false "behind" reading (a wall-clock step, or
|
||||||
|
// an upstream queue a local flush can't drain) — repeated, it would
|
||||||
|
// cost a recovery IDR every cooldown forever. Disarm after two in a
|
||||||
|
// row; the clock-free queue detector keeps covering real backlogs.
|
||||||
|
if clock_behind
|
||||||
|
&& !queue_behind
|
||||||
|
&& flushed < NOOP_FLUSH_DATAGRAMS
|
||||||
|
&& dropped == 0
|
||||||
|
{
|
||||||
|
noop_clock_flushes += 1;
|
||||||
|
if noop_clock_flushes == 1 {
|
||||||
|
// First no-op flush = a wall-clock step is the prime
|
||||||
|
// suspect: ask for an immediate re-sync (sent on the next
|
||||||
|
// report tick). Applied, it resets these counters and
|
||||||
|
// re-arms the detector before the disarm below triggers.
|
||||||
|
resync_wanted = true;
|
||||||
|
}
|
||||||
|
if noop_clock_flushes >= NOOP_CLOCK_FLUSHES_TO_DISARM {
|
||||||
|
clock_detector_armed = false;
|
||||||
|
tracing::warn!(
|
||||||
|
"clock-based jump-to-live disarmed — its flushes found no \
|
||||||
|
local backlog (clock step or upstream queueing suspected); \
|
||||||
|
the queue-depth detector stays armed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
noop_clock_flushes = 0;
|
||||||
|
}
|
||||||
|
continue; // this frame is part of the stale past — don't render it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames.push(frame);
|
||||||
|
}
|
||||||
|
Err(PunktfunkError::NoFrame) => {
|
||||||
|
std::thread::sleep(Duration::from_micros(300));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The pump exited (shutdown / fatal session error) — wake any consumer blocked in
|
||||||
|
// `next_frame` with a Closed signal instead of a spurious timeout (the old mpsc did this
|
||||||
|
// implicitly when the sender dropped).
|
||||||
|
frames.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
|
||||||
|
//! newest packet rather than backing up the QUIC receive path).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) async fn run(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
audio_tx: std::sync::mpsc::SyncSender<AudioPacket>,
|
||||||
|
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||||
|
rumble_feed: super::super::rumble::RumbleFeed,
|
||||||
|
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||||
|
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||||
|
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||||
|
) {
|
||||||
|
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||||
|
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
||||||
|
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
|
||||||
|
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
|
||||||
|
while let Ok(d) = conn.read_datagram().await {
|
||||||
|
match d.first() {
|
||||||
|
Some(&crate::quic::AUDIO_MAGIC) => {
|
||||||
|
if let Some((seq, pts_ns, opus)) = crate::quic::decode_audio_datagram(&d) {
|
||||||
|
let _ = audio_tx.try_send(AudioPacket {
|
||||||
|
seq,
|
||||||
|
pts_ns,
|
||||||
|
data: opus.to_vec(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||||
|
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||||
|
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||||
|
let fresh = match u.envelope {
|
||||||
|
Some(env) => {
|
||||||
|
let idx = u.pad as usize;
|
||||||
|
if idx < crate::input::MAX_PADS {
|
||||||
|
if crate::input::GamepadSnapshot::seq_newer(
|
||||||
|
env.seq,
|
||||||
|
rumble_last_seq[idx],
|
||||||
|
) {
|
||||||
|
rumble_last_seq[idx] = Some(env.seq);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false // reordered/duplicate — drop, keep the newer state
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true // out-of-range pad (host never sends these): no gate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if fresh {
|
||||||
|
let ttl = u.envelope.map(|e| e.ttl_ms);
|
||||||
|
// Both consumers are fed; an embedder drains exactly one of them
|
||||||
|
// (the legacy queue, or the policy engine's command API).
|
||||||
|
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
|
||||||
|
rumble_feed.wire_update(u.pad, u.low, u.high, ttl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(&crate::quic::HIDOUT_MAGIC) => {
|
||||||
|
if let Some(h) = HidOutput::decode(&d) {
|
||||||
|
let _ = hidout_tx.try_send(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||||
|
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||||
|
let _ = hdr_meta_tx.try_send(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(&crate::quic::HOST_TIMING_MAGIC) => {
|
||||||
|
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
|
||||||
|
let _ = host_timing_tx.try_send(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // unknown tag — a newer host; ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Connect + handshake: dial the host (cert-pinned), exchange Hello/Welcome/Start on the
|
||||||
|
//! control stream, run the wall-clock skew handshake, hole-punch the data port, and stand
|
||||||
|
//! up the data-plane [`Session`]. A typed application close from the host surfaces as
|
||||||
|
//! [`PunktfunkError::Rejected`] instead of the generic transport error.
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Everything [`run_pump`](super::run_pump) needs from a successful connect + handshake.
|
||||||
|
pub(super) struct HandshakeOut {
|
||||||
|
pub(super) conn: quinn::Connection,
|
||||||
|
pub(super) session: Session,
|
||||||
|
pub(super) ctrl_send: quinn::SendStream,
|
||||||
|
pub(super) ctrl_recv: io::MsgReader,
|
||||||
|
pub(super) negotiated: Negotiated,
|
||||||
|
pub(super) host_caps: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<HandshakeOut> {
|
||||||
|
let (host, port, pin) = (&args.host, args.port, args.pin);
|
||||||
|
let (mode, compositor, gamepad) = (args.mode, args.compositor, args.gamepad);
|
||||||
|
let (bitrate_kbps, video_caps, audio_channels) =
|
||||||
|
(args.bitrate_kbps, args.video_caps, args.audio_channels);
|
||||||
|
let (video_codecs, preferred_codec, display_hdr) =
|
||||||
|
(args.video_codecs, args.preferred_codec, args.display_hdr);
|
||||||
|
let (launch, identity, shutdown) = (&args.launch, &args.identity, &args.shutdown);
|
||||||
|
let remote: std::net::SocketAddr = join_host_port(host, port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| PunktfunkError::InvalidArg("host:port"))?;
|
||||||
|
let (ep, observed) = endpoint::client_pinned_with_identity(
|
||||||
|
pin,
|
||||||
|
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
||||||
|
);
|
||||||
|
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||||
|
let conn = ep
|
||||||
|
.connect(remote, "punktfunk")
|
||||||
|
.map_err(|_| PunktfunkError::InvalidArg("connect"))?
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
|
||||||
|
// the embedder can distinguish "wrong host identity" from plain IO trouble.
|
||||||
|
let fp_mismatch =
|
||||||
|
pin.is_some() && observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
||||||
|
if fp_mismatch {
|
||||||
|
PunktfunkError::Crypto
|
||||||
|
} else {
|
||||||
|
PunktfunkError::Io(std::io::Error::other(e.to_string()))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
||||||
|
// The rest of the handshake runs in an inner future so a failure can consult
|
||||||
|
// `conn.close_reason()`: a host that turned us away with a typed application close
|
||||||
|
// (pairing not armed / denied / approval timeout / version mismatch / busy) surfaces
|
||||||
|
// as `PunktfunkError::Rejected` instead of the generic transport error the failed
|
||||||
|
// read produces — the difference between "not accepted" and the actual cause.
|
||||||
|
let handshake = async {
|
||||||
|
let (mut send, recv) = conn
|
||||||
|
.open_bi()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||||
|
// Frame every read on this stream through the resumable reader: the control loop
|
||||||
|
// below drives it from a `select!` arm and `clock_sync` wraps it in a timeout, and a
|
||||||
|
// partial frame lost to either would misalign the stream for the whole session.
|
||||||
|
let mut recv = io::MsgReader::new(recv);
|
||||||
|
|
||||||
|
io::write_msg(
|
||||||
|
&mut send,
|
||||||
|
&Hello {
|
||||||
|
abi_version: crate::WIRE_VERSION,
|
||||||
|
mode,
|
||||||
|
compositor,
|
||||||
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
|
// No device name yet: the connect ABI has no name parameter (pairing does). The
|
||||||
|
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||||
|
name: None,
|
||||||
|
// Library id to launch this session, if the embedder asked for one.
|
||||||
|
launch: launch.clone(),
|
||||||
|
// The embedder's decode/present caps (e.g. the Windows client advertises
|
||||||
|
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
|
||||||
|
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
|
||||||
|
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
|
||||||
|
// bit only asks the host for observability datagrams (never changes the encode).
|
||||||
|
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
|
||||||
|
// (every embedder inherits it), so the host may burst speed tests without consuming
|
||||||
|
// video frame indexes.
|
||||||
|
video_caps: video_caps
|
||||||
|
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||||
|
| crate::quic::VIDEO_CAP_PROBE_SEQ,
|
||||||
|
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||||
|
audio_channels,
|
||||||
|
// The codecs this client can decode + its soft preference (0 = auto). The host
|
||||||
|
// resolves the emitted codec from these and reports it in `Welcome::codec`.
|
||||||
|
video_codecs,
|
||||||
|
preferred_codec,
|
||||||
|
// The client display's HDR volume → the host's virtual-display EDID (host apps
|
||||||
|
// tone-map to the client's real panel). `None` = unknown/SDR.
|
||||||
|
display_hdr,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let welcome = Welcome::decode(&recv.read_msg().await?)?;
|
||||||
|
if welcome.compositor != CompositorPref::Auto {
|
||||||
|
tracing::info!(
|
||||||
|
compositor = welcome.compositor.as_str(),
|
||||||
|
"host resolved compositor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if welcome.gamepad != GamepadPref::Auto {
|
||||||
|
tracing::info!(
|
||||||
|
gamepad = welcome.gamepad.as_str(),
|
||||||
|
"host resolved gamepad backend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve our data-plane port, then start the host.
|
||||||
|
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||||
|
let udp_port = probe.local_addr()?.port();
|
||||||
|
drop(probe);
|
||||||
|
io::write_msg(
|
||||||
|
&mut send,
|
||||||
|
&Start {
|
||||||
|
client_udp_port: udp_port,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Wall-clock skew handshake on the control stream (before the session's control task takes
|
||||||
|
// it): align our clock to the host's so the embedder can express receive/present instants in
|
||||||
|
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
|
||||||
|
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
|
||||||
|
let (clock_offset_ns, clock_rtt_ns) =
|
||||||
|
match crate::quic::clock_sync(&mut send, &mut recv).await {
|
||||||
|
Some(skew) => {
|
||||||
|
tracing::info!(
|
||||||
|
offset_ns = skew.offset_ns,
|
||||||
|
rtt_us = skew.rtt_ns / 1000,
|
||||||
|
rounds = skew.rounds,
|
||||||
|
"clock skew estimated (host-client)"
|
||||||
|
);
|
||||||
|
(skew.offset_ns, Some(skew.rtt_ns))
|
||||||
|
}
|
||||||
|
None => (0, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
||||||
|
let transport =
|
||||||
|
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
||||||
|
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall
|
||||||
|
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client
|
||||||
|
// to open the path first). Stops with the session via the shared shutdown flag.
|
||||||
|
if let Ok(sock) = transport.try_clone_socket() {
|
||||||
|
crate::transport::spawn_data_punch(sock, shutdown.clone());
|
||||||
|
}
|
||||||
|
let mut session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
||||||
|
// PyroWave sessions opt into partial delivery (plan §4.4): an aged-out lossy
|
||||||
|
// frame arrives as blocks-with-holes instead of vanishing — the all-intra codec
|
||||||
|
// renders it as one frame of localized blur, strictly better than a freeze.
|
||||||
|
if welcome.codec == crate::quic::CODEC_PYROWAVE {
|
||||||
|
session.set_deliver_partial_frames(true);
|
||||||
|
}
|
||||||
|
Ok::<_, PunktfunkError>((
|
||||||
|
session,
|
||||||
|
send,
|
||||||
|
recv,
|
||||||
|
Negotiated {
|
||||||
|
mode: welcome.mode,
|
||||||
|
compositor: welcome.compositor,
|
||||||
|
gamepad: welcome.gamepad,
|
||||||
|
host_fingerprint: fingerprint,
|
||||||
|
bitrate_kbps: welcome.bitrate_kbps,
|
||||||
|
clock_offset_ns,
|
||||||
|
clock_rtt_ns,
|
||||||
|
bit_depth: welcome.bit_depth,
|
||||||
|
color: welcome.color,
|
||||||
|
chroma_format: welcome.chroma_format,
|
||||||
|
audio_channels: welcome.audio_channels,
|
||||||
|
codec: welcome.codec,
|
||||||
|
shard_payload: welcome.shard_payload,
|
||||||
|
host_caps: welcome.host_caps,
|
||||||
|
},
|
||||||
|
welcome.host_caps,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
match handshake.await {
|
||||||
|
Ok((session, send, recv, negotiated, host_caps)) => Ok(HandshakeOut {
|
||||||
|
conn,
|
||||||
|
session,
|
||||||
|
ctrl_send: send,
|
||||||
|
ctrl_recv: recv,
|
||||||
|
negotiated,
|
||||||
|
host_caps,
|
||||||
|
}),
|
||||||
|
Err(e) => Err(match reject_from_close(&conn) {
|
||||||
|
Some(r) => PunktfunkError::Rejected(r),
|
||||||
|
None => e,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Input task: embedder events → QUIC datagrams. Toward a host that advertised
|
||||||
|
//! HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
|
||||||
|
//! folded into idempotent, sequence-numbered full-state snapshots (`GamepadSnapshot`): the
|
||||||
|
//! datagram plane drops and reorders (and sheds oldest-first at the 4 KiB send cap), so a lost
|
||||||
|
//! per-transition event would corrupt held pad state until the *next* change — a held trigger
|
||||||
|
//! stuck wrong indefinitely. Snapshots heal on the next send, the seq lets the host drop stale
|
||||||
|
//! reorders, and a periodic refresh of every touched pad bounds any loss to one refresh
|
||||||
|
//! interval — the same idempotent-state discipline as the host's 500 ms rumble refresh.
|
||||||
|
//! Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
||||||
|
//! getting the legacy per-transition gamepad events.
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) async fn run(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
|
gamepad_snapshots: bool,
|
||||||
|
) {
|
||||||
|
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||||
|
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||||
|
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||||
|
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||||
|
// Per-pad wrapping seq that PERSISTS across a pad's remove/re-add on the same index (the
|
||||||
|
// snapshot itself is cleared to `None` on removal). A removal takes `seq[idx] + 1` so it
|
||||||
|
// supersedes every prior snapshot; the re-added pad's first snapshot takes the next value
|
||||||
|
// after that, so the host's seq gate accepts it instead of rejecting a restarted-at-0 seq.
|
||||||
|
let mut seq: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||||
|
// Re-sends of a removal still owed on refresh ticks (the removal rides the lossy datagram
|
||||||
|
// plane; a single lost one would silently strand a ghost pad on the host — the exact bug
|
||||||
|
// the removal fixes). Mirrors the host's rumble stop burst: a few time-spread re-sends,
|
||||||
|
// each with a fresh (higher) seq, and canceled the moment the pad is driven again.
|
||||||
|
const REMOVE_RESENDS: u8 = 2;
|
||||||
|
let mut remove_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||||
|
// Per-pad declared controller kind ([`GamepadArrival`]) + its owed re-sends: the host needs
|
||||||
|
// the kind before the pad's first frame to build a matching virtual device (mixed types), so
|
||||||
|
// like the removal it rides the lossy plane with a small time-spread re-send burst.
|
||||||
|
const ARRIVAL_RESENDS: u8 = 2;
|
||||||
|
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||||
|
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||||
|
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||||
|
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
ev = input_rx.recv() => {
|
||||||
|
let Some(ev) = ev else { break };
|
||||||
|
let idx = ev.flags as usize;
|
||||||
|
if gamepad_snapshots
|
||||||
|
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
|
||||||
|
&& idx < MAX_PADS
|
||||||
|
{
|
||||||
|
// The pad is being driven — cancel any owed removal (a re-plug on this
|
||||||
|
// index; its fresh snapshot seq already supersedes the removal's).
|
||||||
|
remove_owed[idx] = 0;
|
||||||
|
let snap = pads[idx].get_or_insert(GamepadSnapshot {
|
||||||
|
pad: idx as u8,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// Unknown axis ids don't send (the host's legacy fold drops them too).
|
||||||
|
if snap.fold(&ev) {
|
||||||
|
seq[idx] = seq[idx].wrapping_add(1);
|
||||||
|
snap.seq = seq[idx];
|
||||||
|
let _ = conn
|
||||||
|
.send_datagram(snap.to_event().encode().to_vec().into());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if gamepad_snapshots && ev.kind == InputKind::GamepadRemove && idx < MAX_PADS {
|
||||||
|
// Stop refreshing the pad and forward a seq-stamped removal (in the shared
|
||||||
|
// seq space) so the host tears its virtual device down and no reordered
|
||||||
|
// snapshot can resurrect it; arm the re-send burst against datagram loss.
|
||||||
|
// Drop any owed kind declaration too — a re-plug on this index sends its own.
|
||||||
|
pads[idx] = None;
|
||||||
|
arrival[idx] = None;
|
||||||
|
arrival_owed[idx] = 0;
|
||||||
|
seq[idx] = seq[idx].wrapping_add(1);
|
||||||
|
remove_owed[idx] = REMOVE_RESENDS;
|
||||||
|
let rem = crate::input::InputEvent {
|
||||||
|
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
|
||||||
|
..ev
|
||||||
|
};
|
||||||
|
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||||
|
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||||
|
// so the host learns it before the pad's first frame even under loss.
|
||||||
|
arrival[idx] = Some(ev.code as u8);
|
||||||
|
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||||
|
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||||
|
}
|
||||||
|
_ = refresh.tick() => {
|
||||||
|
for idx in 0..MAX_PADS {
|
||||||
|
// Re-send an owed kind declaration (independent of whether the pad has state
|
||||||
|
// yet — it may be idle-but-connected). Idempotent on the host.
|
||||||
|
if arrival_owed[idx] > 0 {
|
||||||
|
if let Some(kind) = arrival[idx] {
|
||||||
|
arrival_owed[idx] -= 1;
|
||||||
|
let arr = crate::input::InputEvent {
|
||||||
|
kind: InputKind::GamepadArrival,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: kind as u32,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
flags: idx as u32,
|
||||||
|
};
|
||||||
|
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||||
|
} else {
|
||||||
|
arrival_owed[idx] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(snap) = pads[idx].as_mut() {
|
||||||
|
seq[idx] = seq[idx].wrapping_add(1);
|
||||||
|
snap.seq = seq[idx];
|
||||||
|
let _ = conn.send_datagram(snap.to_event().encode().to_vec().into());
|
||||||
|
} else if remove_owed[idx] > 0 {
|
||||||
|
// Idempotent removal re-send with a fresh seq (the host drops it as a
|
||||||
|
// no-op once the pad is already gone, but a re-plug's later snapshot
|
||||||
|
// still wins by seq).
|
||||||
|
remove_owed[idx] -= 1;
|
||||||
|
seq[idx] = seq[idx].wrapping_add(1);
|
||||||
|
let rem = crate::input::InputEvent {
|
||||||
|
kind: InputKind::GamepadRemove,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
|
||||||
|
};
|
||||||
|
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,11 @@ pub async fn run(
|
|||||||
let Some(cmd) = cmd else { break }; // NativeClient dropped
|
let Some(cmd) = cmd else { break }; // NativeClient dropped
|
||||||
match cmd {
|
match cmd {
|
||||||
ClipCommand::Fetch { xfer_id, seq, file_index, mime } => {
|
ClipCommand::Fetch { xfer_id, seq, file_index, mime } => {
|
||||||
|
// Prune finished fetches first: a completed/failed/timed-out fetch task
|
||||||
|
// drops its cancel receiver, so its sender reads closed. Without this
|
||||||
|
// the map grew by one dead sender per paste for the whole session (only
|
||||||
|
// an explicit Cancel ever removed entries).
|
||||||
|
fetch_cancels.retain(|_, tx| !tx.is_closed());
|
||||||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||||||
fetch_cancels.insert(xfer_id, cancel_tx);
|
fetch_cancels.insert(xfer_id, cancel_tx);
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
@@ -153,11 +158,36 @@ pub async fn run(
|
|||||||
tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx));
|
tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx));
|
||||||
}
|
}
|
||||||
ClipCommand::Serve { req_id, bytes, last } => {
|
ClipCommand::Serve { req_id, bytes, last } => {
|
||||||
serve_bufs.entry(req_id).or_default().extend_from_slice(&bytes);
|
// Gate on a genuinely parked fetch (the waiter registers before the
|
||||||
if last {
|
// FetchRequest event is emitted, so a live serve always finds it):
|
||||||
let full = serve_bufs.remove(&req_id).unwrap_or_default();
|
// bytes served under a stale/unknown/cancelled req_id would otherwise
|
||||||
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
|
// pool here for the whole session with Ok returned for every chunk.
|
||||||
let _ = tx.send(Some(full));
|
if !serve_waiters.lock().unwrap().contains_key(&req_id) {
|
||||||
|
serve_bufs.remove(&req_id);
|
||||||
|
} else {
|
||||||
|
let buf = serve_bufs.entry(req_id).or_default();
|
||||||
|
if buf.len().saturating_add(bytes.len()) > CLIP_FETCH_CAP {
|
||||||
|
// The requester bounds its read at CLIP_FETCH_CAP — anything
|
||||||
|
// larger is memory burned toward a guaranteed peer rejection.
|
||||||
|
// Fail the transfer NOW: peer reads UNAVAILABLE, embedder gets
|
||||||
|
// an Error instead of silent Ok-per-chunk.
|
||||||
|
serve_bufs.remove(&req_id);
|
||||||
|
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
|
||||||
|
let _ = tx.send(None);
|
||||||
|
}
|
||||||
|
let _ = events.try_send(ClipEventCore::Error {
|
||||||
|
id: req_id,
|
||||||
|
code: PunktfunkStatus::InvalidArg as i32,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
buf.extend_from_slice(&bytes);
|
||||||
|
if last {
|
||||||
|
let full = serve_bufs.remove(&req_id).unwrap_or_default();
|
||||||
|
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id)
|
||||||
|
{
|
||||||
|
let _ = tx.send(Some(full));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,8 +254,27 @@ async fn serve_inbound(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match rx.await {
|
// Overall stall bound (§3.4) — the inbound mirror of `run_outbound_fetch`'s: an embedder
|
||||||
Ok(Some(bytes)) => {
|
// that never answers must not hold the waiter, this task, and the accepted bi-stream open
|
||||||
|
// for the rest of the session (~100 unanswered pastes exhaust the connection's bidi-stream
|
||||||
|
// budget and every later host paste stalls). `send.stopped()` additionally wakes us the
|
||||||
|
// moment the peer gives up on its side of the fetch.
|
||||||
|
let answer = tokio::select! {
|
||||||
|
r = rx => r.ok().flatten(),
|
||||||
|
_ = send.stopped() => {
|
||||||
|
let _ = events.try_send(ClipEventCore::Cancelled { id: req_id });
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(FETCH_STALL_SECS)) => {
|
||||||
|
let _ = events.try_send(ClipEventCore::Cancelled { id: req_id });
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Idempotent — the answering/denying paths already removed it; the stall paths must.
|
||||||
|
waiters.lock().unwrap().remove(&req_id);
|
||||||
|
|
||||||
|
match answer {
|
||||||
|
Some(bytes) => {
|
||||||
if clipstream::write_fetch_hdr(
|
if clipstream::write_fetch_hdr(
|
||||||
&mut send,
|
&mut send,
|
||||||
&ClipFetchHdr {
|
&ClipFetchHdr {
|
||||||
@@ -302,3 +351,65 @@ async fn run_outbound_fetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::quic::test_util::connect_pair;
|
||||||
|
use crate::quic::CLIP_FILE_INDEX_NONE;
|
||||||
|
|
||||||
|
/// A serve chunk that alone breaches the requester-side CLIP_FETCH_CAP must fail the
|
||||||
|
/// transfer immediately — Error to the embedder, UNAVAILABLE to the peer — instead of
|
||||||
|
/// accumulating Ok-per-chunk toward a guaranteed peer-side rejection. Also pins the
|
||||||
|
/// waiter-membership gate: the serve only accumulated because the fetch was parked.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn oversized_serve_fails_the_transfer_instead_of_buffering() {
|
||||||
|
let (_s, _c, host_conn, client_conn) = connect_pair().await;
|
||||||
|
let (ev_tx, ev_rx) = std::sync::mpsc::sync_channel(16);
|
||||||
|
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
tokio::spawn(run(client_conn, ev_tx, cmd_rx));
|
||||||
|
|
||||||
|
// Host pastes: it opens a fetch bi-stream toward the client.
|
||||||
|
let req = ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
};
|
||||||
|
let (_send, mut recv) = clipstream::open_fetch(&host_conn, &req).await.unwrap();
|
||||||
|
|
||||||
|
// The client core surfaces the FetchRequest (the waiter is parked now).
|
||||||
|
let (req_id, ev_rx) = tokio::task::spawn_blocking(move || {
|
||||||
|
match ev_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
ClipEventCore::FetchRequest { req_id, .. } => (req_id, ev_rx),
|
||||||
|
other => panic!("expected FetchRequest, got {other:?}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
cmd_tx
|
||||||
|
.send(ClipCommand::Serve {
|
||||||
|
req_id,
|
||||||
|
bytes: vec![0u8; CLIP_FETCH_CAP + 1],
|
||||||
|
last: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let ev = tokio::task::spawn_blocking(move || {
|
||||||
|
ev_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
match ev {
|
||||||
|
ClipEventCore::Error { id, .. } => assert_eq!(id, req_id),
|
||||||
|
other => panic!("expected Error, got {other:?}"),
|
||||||
|
}
|
||||||
|
// The peer's read side sees the transfer refused, not a hang.
|
||||||
|
let hdr = clipstream::read_fetch_hdr(&mut recv).await.unwrap();
|
||||||
|
assert_eq!(hdr.status, CLIP_FETCH_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -548,4 +548,100 @@ mod tests {
|
|||||||
Some(SteamController)
|
Some(SteamController)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compositor_pref_wire_and_names() {
|
||||||
|
for p in [
|
||||||
|
CompositorPref::Auto,
|
||||||
|
CompositorPref::Kwin,
|
||||||
|
CompositorPref::Wlroots,
|
||||||
|
CompositorPref::Mutter,
|
||||||
|
CompositorPref::Gamescope,
|
||||||
|
] {
|
||||||
|
assert_eq!(CompositorPref::from_u8(p.to_u8()), p);
|
||||||
|
assert_eq!(CompositorPref::from_name(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
// Aliases + unknowns.
|
||||||
|
assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin));
|
||||||
|
assert_eq!(
|
||||||
|
CompositorPref::from_name("sway"),
|
||||||
|
Some(CompositorPref::Wlroots)
|
||||||
|
);
|
||||||
|
assert_eq!(CompositorPref::from_name("nope"), None);
|
||||||
|
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||||
|
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_pref_wire_and_names() {
|
||||||
|
for p in [
|
||||||
|
GamepadPref::Auto,
|
||||||
|
GamepadPref::Xbox360,
|
||||||
|
GamepadPref::DualSense,
|
||||||
|
GamepadPref::XboxOne,
|
||||||
|
GamepadPref::DualShock4,
|
||||||
|
GamepadPref::SteamController,
|
||||||
|
GamepadPref::SteamDeck,
|
||||||
|
GamepadPref::DualSenseEdge,
|
||||||
|
GamepadPref::SwitchPro,
|
||||||
|
GamepadPref::SteamController2,
|
||||||
|
GamepadPref::SteamController2Puck,
|
||||||
|
] {
|
||||||
|
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||||
|
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
// Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers
|
||||||
|
// that only know a prefix of the range).
|
||||||
|
for (v, p) in [
|
||||||
|
(0, GamepadPref::Auto),
|
||||||
|
(1, GamepadPref::Xbox360),
|
||||||
|
(2, GamepadPref::DualSense),
|
||||||
|
(3, GamepadPref::XboxOne),
|
||||||
|
(4, GamepadPref::DualShock4),
|
||||||
|
(5, GamepadPref::SteamController),
|
||||||
|
(6, GamepadPref::SteamDeck),
|
||||||
|
(7, GamepadPref::DualSenseEdge),
|
||||||
|
(8, GamepadPref::SwitchPro),
|
||||||
|
(9, GamepadPref::SteamController2),
|
||||||
|
(10, GamepadPref::SteamController2Puck),
|
||||||
|
] {
|
||||||
|
assert_eq!(p.to_u8(), v);
|
||||||
|
assert_eq!(GamepadPref::from_u8(v), p);
|
||||||
|
}
|
||||||
|
// The next unassigned byte degrades to Auto today; assigning it later must update this.
|
||||||
|
assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto);
|
||||||
|
// Aliases + unknowns.
|
||||||
|
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||||
|
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||||
|
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("edge"),
|
||||||
|
Some(GamepadPref::DualSenseEdge)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("Switch-Pro"),
|
||||||
|
Some(GamepadPref::SwitchPro)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("ibex"),
|
||||||
|
Some(GamepadPref::SteamController2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("sc2"),
|
||||||
|
Some(GamepadPref::SteamController2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("sc2puck"),
|
||||||
|
Some(GamepadPref::SteamController2Puck)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("xbox-one"),
|
||||||
|
Some(GamepadPref::XboxOne)
|
||||||
|
);
|
||||||
|
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
|
||||||
|
assert_eq!(GamepadPref::from_name("nope"), None);
|
||||||
|
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||||
|
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,18 @@ impl ErasureCoder for Gf8Coder {
|
|||||||
// No FEC: every original must already be present.
|
// No FEC: every original must already be present.
|
||||||
return collect_originals(received, data_count);
|
return collect_originals(received, data_count);
|
||||||
}
|
}
|
||||||
let rs = ReedSolomon::new(data_count, recovery_count)
|
// Same (k, m)-keyed cache as `encode_into`: a fresh ReedSolomon per lossy block costs a
|
||||||
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
// full generator build (k×2k Gauss-Jordan + total×k×k multiply) on the real-time pump
|
||||||
|
// thread AND forfeits the instance's decode-matrix cache for stable loss patterns.
|
||||||
|
let mut guard = self.rs.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let cached =
|
||||||
|
matches!(&*guard, Some((ck, cm, _)) if *ck == data_count && *cm == recovery_count);
|
||||||
|
if !cached {
|
||||||
|
let rs = ReedSolomon::new(data_count, recovery_count)
|
||||||
|
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
||||||
|
*guard = Some((data_count, recovery_count, rs));
|
||||||
|
}
|
||||||
|
let rs = &guard.as_ref().expect("cache populated above").2;
|
||||||
rs.reconstruct_data(received)
|
rs.reconstruct_data(received)
|
||||||
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
||||||
collect_originals(received, data_count)
|
collect_originals(received, data_count)
|
||||||
@@ -116,8 +126,17 @@ impl ErasureCoder for Gf8Coder {
|
|||||||
for &(j, bytes) in recovery {
|
for &(j, bytes) in recovery {
|
||||||
received[data_count + j] = Some(bytes.to_vec());
|
received[data_count + j] = Some(bytes.to_vec());
|
||||||
}
|
}
|
||||||
let rs = ReedSolomon::new(data_count, recovery_count)
|
// Cache the codec by (k, m) exactly as `encode_into`/`reconstruct` do (see the note
|
||||||
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
// there) — this path runs per lossy block on the pump thread.
|
||||||
|
let mut guard = self.rs.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let cached =
|
||||||
|
matches!(&*guard, Some((ck, cm, _)) if *ck == data_count && *cm == recovery_count);
|
||||||
|
if !cached {
|
||||||
|
let rs = ReedSolomon::new(data_count, recovery_count)
|
||||||
|
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
||||||
|
*guard = Some((data_count, recovery_count, rs));
|
||||||
|
}
|
||||||
|
let rs = &guard.as_ref().expect("cache populated above").2;
|
||||||
rs.reconstruct_data(&mut received)
|
rs.reconstruct_data(&mut received)
|
||||||
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
||||||
for (i, h) in have.iter().enumerate() {
|
for (i, h) in have.iter().enumerate() {
|
||||||
|
|||||||
@@ -16,6 +16,17 @@
|
|||||||
//! (recv → open → reorder → FEC recover → reassemble) state machines.
|
//! (recv → open → reorder → FEC recover → reassemble) state machines.
|
||||||
//! - [`transport`] — pluggable packet I/O (in-process loopback for tests; UDP for real).
|
//! - [`transport`] — pluggable packet I/O (in-process loopback for tests; UDP for real).
|
||||||
//! - [`abi`] — the `extern "C"` surface and `cbindgen`-generated `punktfunk_core.h`.
|
//! - [`abi`] — the `extern "C"` surface and `cbindgen`-generated `punktfunk_core.h`.
|
||||||
|
//! - [`config`] / [`error`] / [`stats`] — session configuration, the shared error/status
|
||||||
|
//! vocabulary, and the counters snapshot.
|
||||||
|
//! - [`input`] — the wire input-event vocabulary (keyboard/mouse/touch, gamepad snapshots).
|
||||||
|
//! - [`reject`] — typed application-close rejection codes · [`reanchor`] — the post-loss
|
||||||
|
//! freeze-until-reanchor client gate · [`render_scale`] — the shared render-scale setting ·
|
||||||
|
//! [`audio`] — Opus PCM decode for C-ABI embedders · [`wol`] — Wake-on-LAN.
|
||||||
|
//! - `quic` (feature `quic`) — the punktfunk/1 control plane: handshake, typed control
|
||||||
|
//! messages, pairing (SPAKE2), the datagram plane codecs, and clock sync. With it come
|
||||||
|
//! `client` (the embeddable NativeClient worker), `abr` (the adaptive-bitrate
|
||||||
|
//! controller), and `clipboard` (the shared-clipboard transport task). `tls`
|
||||||
|
//! (feature `tls`) — the pinned-fingerprint certificate verifier.
|
||||||
//!
|
//!
|
||||||
//! ## Threading contract
|
//! ## Threading contract
|
||||||
//!
|
//!
|
||||||
@@ -88,7 +99,10 @@ pub use stats::Stats;
|
|||||||
/// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
|
/// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
|
||||||
/// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
|
/// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
|
||||||
/// is unchanged.
|
/// is unchanged.
|
||||||
pub const ABI_VERSION: u32 = 9;
|
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||||
|
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||||
|
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||||
|
pub const ABI_VERSION: u32 = 10;
|
||||||
|
|
||||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||||
|
|||||||
@@ -518,6 +518,10 @@ impl Reassembler {
|
|||||||
// The dropped frames' buffers (and their parity bufs) go back to the allocator, not the
|
// The dropped frames' buffers (and their parity bufs) go back to the allocator, not the
|
||||||
// pool — a flush is the rare path. The budget resets with them.
|
// pool — a flush is the rare path. The budget resets with them.
|
||||||
self.in_flight_bytes = 0;
|
self.in_flight_bytes = 0;
|
||||||
|
// An aged-out partial parked for delivery is from the discarded past too — without this
|
||||||
|
// it survives `flush_backlog` and gets handed up as the first "frame" after the
|
||||||
|
// jump-to-live, exactly the stale content the flush existed to discard.
|
||||||
|
self.pending_partial = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,3 +649,35 @@ impl ReassemblyWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reset_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `flush_backlog` discards the past wholesale — an aged-out partial parked for delivery is
|
||||||
|
/// part of that past and must not survive [`Reassembler::reset`] to be handed up as the
|
||||||
|
/// first "frame" after a jump-to-live.
|
||||||
|
#[test]
|
||||||
|
fn reset_drops_a_parked_partial() {
|
||||||
|
let mut r = Reassembler::new(ReassemblerLimits {
|
||||||
|
shard_bytes: 64,
|
||||||
|
max_data_shards: 8,
|
||||||
|
max_total_shards: 16,
|
||||||
|
max_blocks: 4,
|
||||||
|
max_frame_bytes: 4096,
|
||||||
|
});
|
||||||
|
r.pending_partial = Some(Frame {
|
||||||
|
data: vec![0u8; 64],
|
||||||
|
frame_index: 7,
|
||||||
|
pts_ns: 1,
|
||||||
|
flags: 0,
|
||||||
|
complete: false,
|
||||||
|
received_ns: 0,
|
||||||
|
});
|
||||||
|
r.reset();
|
||||||
|
assert!(
|
||||||
|
r.take_partial().is_none(),
|
||||||
|
"a pre-flush partial must not survive reset()"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,8 +91,13 @@ pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Opti
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Honor the client's preference when the host can also emit it; else fall back to precedence.
|
// Honor the client's preference when the host can also emit it; else fall back to precedence.
|
||||||
|
// `preferred` is a single-bit field by contract but arrives as a raw wire byte — isolate ONE
|
||||||
|
// bit of the intersection instead of echoing the request, so a non-conformant multi-bit
|
||||||
|
// value can never escape as a codec id (downstream `from_wire` folds unknown values to HEVC,
|
||||||
|
// which may not even be in the shared set).
|
||||||
if preferred != 0 && shared & preferred != 0 {
|
if preferred != 0 && shared & preferred != 0 {
|
||||||
return Some(preferred);
|
let want = shared & preferred;
|
||||||
|
return Some(want & want.wrapping_neg());
|
||||||
}
|
}
|
||||||
// Precedence: HEVC > AV1 > H.264.
|
// Precedence: HEVC > AV1 > H.264.
|
||||||
[CODEC_HEVC, CODEC_AV1, CODEC_H264]
|
[CODEC_HEVC, CODEC_AV1, CODEC_H264]
|
||||||
@@ -171,3 +176,75 @@ impl Default for ColorInfo {
|
|||||||
Self::SDR_BT709
|
Self::SDR_BT709
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode};
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_cap_clipboard_bit_is_distinct_and_survives_welcome() {
|
||||||
|
// The new cap packs into the existing trailing host_caps byte with no layout change.
|
||||||
|
assert_ne!(HOST_CAP_CLIPBOARD, HOST_CAP_GAMEPAD_STATE);
|
||||||
|
let mut w = Welcome {
|
||||||
|
abi_version: 1,
|
||||||
|
udp_port: 1,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 0,
|
||||||
|
max_data_per_block: 1024,
|
||||||
|
},
|
||||||
|
shard_payload: 1024,
|
||||||
|
encrypt: false,
|
||||||
|
key: [0; 16],
|
||||||
|
salt: [0; 4],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||||
|
};
|
||||||
|
let got = Welcome::decode(&w.encode()).unwrap();
|
||||||
|
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
||||||
|
assert_eq!(
|
||||||
|
got.host_caps & HOST_CAP_GAMEPAD_STATE,
|
||||||
|
HOST_CAP_GAMEPAD_STATE
|
||||||
|
);
|
||||||
|
// Clipboard-off host: the bit is clear, gamepad bit still set.
|
||||||
|
w.host_caps = HOST_CAP_GAMEPAD_STATE;
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&w.encode()).unwrap().host_caps & HOST_CAP_CLIPBOARD,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||||
|
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||||
|
// must still be a single bit of the shared set, never the raw multi-bit echo (which
|
||||||
|
// folds to HEVC downstream and can select a codec the client can't decode).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264, CODEC_H264 | CODEC_AV1, CODEC_H264 | CODEC_AV1),
|
||||||
|
Some(CODEC_H264)
|
||||||
|
);
|
||||||
|
// Several shared preferred bits: still exactly one bit, and one of the preferred ones.
|
||||||
|
let got = resolve_codec(
|
||||||
|
CODEC_H264 | CODEC_HEVC | CODEC_AV1,
|
||||||
|
CODEC_H264 | CODEC_HEVC | CODEC_AV1,
|
||||||
|
CODEC_AV1 | CODEC_HEVC,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got.count_ones(), 1);
|
||||||
|
assert_ne!(got & (CODEC_AV1 | CODEC_HEVC), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,3 +115,134 @@ pub async fn read_data(recv: &mut quinn::RecvStream, max_bytes: usize) -> std::i
|
|||||||
.await
|
.await
|
||||||
.map_err(std::io::Error::other)
|
.map_err(std::io::Error::other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-process QUIC loopback: the real clipstream fetch transport, both success and cancel.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::clipstream;
|
||||||
|
use crate::quic::test_util::connect_pair;
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_text_transfers_then_cancel_resets() {
|
||||||
|
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||||
|
|
||||||
|
let payload = b"hello clipboard \xf0\x9f\x93\x8b".to_vec(); // text + a 4-byte emoji
|
||||||
|
let holder_payload = payload.clone();
|
||||||
|
|
||||||
|
// Holder = the host side: accept two fetch streams. Serve the first; cancel the second.
|
||||||
|
let holder = tokio::spawn(async move {
|
||||||
|
// Fetch #1 — serve the payload.
|
||||||
|
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept fetch #1");
|
||||||
|
let kind = clipstream::read_stream_header(&mut recv)
|
||||||
|
.await
|
||||||
|
.expect("stream header #1");
|
||||||
|
assert_eq!(kind, clipstream::CLIP_STREAM_KIND_FETCH);
|
||||||
|
let req = clipstream::read_fetch(&mut recv)
|
||||||
|
.await
|
||||||
|
.expect("fetch req #1");
|
||||||
|
assert_eq!(req.seq, 1);
|
||||||
|
assert_eq!(req.file_index, CLIP_FILE_INDEX_NONE);
|
||||||
|
assert_eq!(req.mime, "text/plain;charset=utf-8");
|
||||||
|
clipstream::write_fetch_hdr(
|
||||||
|
&mut send,
|
||||||
|
&ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: holder_payload.len() as u64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write hdr #1");
|
||||||
|
clipstream::write_data(&mut send, &holder_payload)
|
||||||
|
.await
|
||||||
|
.expect("write data #1");
|
||||||
|
|
||||||
|
// Fetch #2 — read the request, then cancel mid-transfer with RESET_STREAM.
|
||||||
|
let (mut send2, mut recv2) = host_conn.accept_bi().await.expect("accept fetch #2");
|
||||||
|
clipstream::read_stream_header(&mut recv2)
|
||||||
|
.await
|
||||||
|
.expect("stream header #2");
|
||||||
|
let _ = clipstream::read_fetch(&mut recv2)
|
||||||
|
.await
|
||||||
|
.expect("fetch req #2");
|
||||||
|
send2.reset(clipstream::cancelled_code()).unwrap();
|
||||||
|
|
||||||
|
host_conn // keep alive until the requester side is done
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requester = the client side.
|
||||||
|
// #1: full lazy fetch of the text payload.
|
||||||
|
let req = ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
};
|
||||||
|
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req)
|
||||||
|
.await
|
||||||
|
.expect("open fetch #1");
|
||||||
|
let hdr = clipstream::read_fetch_hdr(&mut recv)
|
||||||
|
.await
|
||||||
|
.expect("read hdr #1");
|
||||||
|
assert_eq!(hdr.status, CLIP_FETCH_OK);
|
||||||
|
assert_eq!(hdr.total_size as usize, payload.len());
|
||||||
|
let got = clipstream::read_data(&mut recv, 8 << 20)
|
||||||
|
.await
|
||||||
|
.expect("read data #1");
|
||||||
|
assert_eq!(got, payload);
|
||||||
|
|
||||||
|
// #2: the holder resets the stream — the requester surfaces an error rather than hanging.
|
||||||
|
let req2 = ClipFetch {
|
||||||
|
seq: 2,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
};
|
||||||
|
let (_send2, mut recv2) = clipstream::open_fetch(&client_conn, &req2)
|
||||||
|
.await
|
||||||
|
.expect("open fetch #2");
|
||||||
|
assert!(
|
||||||
|
clipstream::read_fetch_hdr(&mut recv2).await.is_err(),
|
||||||
|
"a cancelled fetch must surface as an error, not a hang"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _host_conn = holder.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_data_enforces_size_cap() {
|
||||||
|
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||||
|
|
||||||
|
let big = vec![0xABu8; 200_000]; // > the 64 KiB chunk, and > the cap we set below
|
||||||
|
let holder_payload = big.clone();
|
||||||
|
let holder = tokio::spawn(async move {
|
||||||
|
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept");
|
||||||
|
clipstream::read_stream_header(&mut recv).await.unwrap();
|
||||||
|
let _ = clipstream::read_fetch(&mut recv).await.unwrap();
|
||||||
|
clipstream::write_fetch_hdr(
|
||||||
|
&mut send,
|
||||||
|
&ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: holder_payload.len() as u64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _ = clipstream::write_data(&mut send, &holder_payload).await;
|
||||||
|
host_conn
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "application/octet-stream".into(),
|
||||||
|
};
|
||||||
|
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clipstream::read_fetch_hdr(&mut recv).await.unwrap().status,
|
||||||
|
CLIP_FETCH_OK
|
||||||
|
);
|
||||||
|
// Cap below the payload size ⇒ read_data errors instead of buffering unboundedly.
|
||||||
|
assert!(clipstream::read_data(&mut recv, 64 * 1024).await.is_err());
|
||||||
|
|
||||||
|
let _host_conn = holder.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,3 +154,115 @@ impl Default for ClockResync {
|
|||||||
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
|
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
|
||||||
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
|
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_offset_picks_min_rtt_and_recovers_offset() {
|
||||||
|
// Host clock is +1_000_000 ns ahead of the client. Construct samples where a symmetric
|
||||||
|
// round-trip recovers exactly that offset, and a noisy (asymmetric, high-RTT) sample is
|
||||||
|
// present but must be ignored by the min-RTT selection.
|
||||||
|
const OFF: i64 = 1_000_000;
|
||||||
|
// Clean sample: client t1=0, one-way=200µs each way → t2 = t1 + 200_000 + OFF (host clock),
|
||||||
|
// t3 = t2 + 50_000 (host processing), t4 = t3 - OFF + 200_000 (back in client clock).
|
||||||
|
let t1 = 0u64;
|
||||||
|
let t2 = (t1 as i64 + 200_000 + OFF) as u64;
|
||||||
|
let t3 = t2 + 50_000;
|
||||||
|
let t4 = (t3 as i64 - OFF + 200_000) as u64;
|
||||||
|
// Noisy sample: same offset but a fat, asymmetric RTT (slow return path) — higher RTT.
|
||||||
|
let n1 = 1_000_000u64;
|
||||||
|
let n2 = (n1 as i64 + 200_000 + OFF) as u64;
|
||||||
|
let n3 = n2 + 50_000;
|
||||||
|
let n4 = (n3 as i64 - OFF + 5_000_000) as u64; // 5 ms return → big RTT
|
||||||
|
let (offset, rtt) =
|
||||||
|
clock_offset_ns(&[(n1, n2, n3, n4), (t1, t2, t3, t4)]).expect("non-empty");
|
||||||
|
// The min-RTT sample recovers the offset exactly; its RTT is 2x200us, and the noisy
|
||||||
|
// (asymmetric, 5 ms return) sample is ignored by the min-RTT selection.
|
||||||
|
assert_eq!(offset, OFF);
|
||||||
|
assert_eq!(rtt, 400_000);
|
||||||
|
assert!(clock_offset_ns(&[]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mid-stream re-sync state machine: 8 rounds collected via matched echoes, stale
|
||||||
|
/// echoes ignored, a restarted batch abandons the old one, and the batch result is the
|
||||||
|
/// min-RTT estimate — the exact behavior the connect-time `clock_sync` loop has.
|
||||||
|
#[test]
|
||||||
|
fn clock_resync_collects_rounds_and_ignores_stale_echoes() {
|
||||||
|
// Host clock +1 ms ahead; symmetric 100 µs one-way paths except one congested round.
|
||||||
|
const OFF: i64 = 1_000_000;
|
||||||
|
let echo_for = |t1: u64, one_way: u64| ClockEcho {
|
||||||
|
t1_ns: t1,
|
||||||
|
t2_ns: (t1 as i64 + one_way as i64 + OFF) as u64,
|
||||||
|
t3_ns: (t1 as i64 + one_way as i64 + OFF) as u64 + 10_000,
|
||||||
|
};
|
||||||
|
let t4_for = |e: &ClockEcho, one_way: u64| (e.t3_ns as i64 - OFF + one_way as i64) as u64;
|
||||||
|
|
||||||
|
let mut rs = ClockResync::new();
|
||||||
|
// An unsolicited echo before any batch is ignored.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(42, 100_000), 500_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut probe = rs.begin(1_000_000);
|
||||||
|
// A stale echo (wrong t1: the abandoned pre-begin probe) is ignored mid-batch.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(42, 100_000), 500_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
for round in 0..ClockResync::ROUNDS {
|
||||||
|
// Round 3 is congested (5 ms one-way) — it must lose the min-RTT selection.
|
||||||
|
let one_way = if round == 3 { 5_000_000 } else { 100_000 };
|
||||||
|
let echo = echo_for(probe.t1_ns, one_way);
|
||||||
|
let t4 = t4_for(&echo, one_way);
|
||||||
|
match rs.on_echo(&echo, t4) {
|
||||||
|
ResyncStep::Probe(p) => {
|
||||||
|
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
|
||||||
|
probe = p;
|
||||||
|
}
|
||||||
|
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||||
|
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
|
||||||
|
assert_eq!(offset_ns, OFF, "min-RTT round recovers the offset exactly");
|
||||||
|
assert_eq!(rtt_ns, 200_000); // 2×100 µs; host processing (t3−t2) excluded
|
||||||
|
}
|
||||||
|
ResyncStep::Idle => panic!("matched echo must advance the batch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The batch is done: even a matching-t1 replay no longer advances anything.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(probe.t1_ns, 100_000), probe.t1_ns + 300_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
// begin() mid-batch abandons the in-flight batch: its echo is stale afterwards.
|
||||||
|
let old = rs.begin(2_000_000);
|
||||||
|
let fresh = rs.begin(3_000_000);
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
|
||||||
|
ResyncStep::Probe(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
|
||||||
|
/// replace the offset — its queueing delay biases the estimate exactly when frames
|
||||||
|
/// already read late. Floor of 2 ms so a near-zero connect RTT (same-host/LAN) doesn't
|
||||||
|
/// reject every later batch over normal jitter.
|
||||||
|
#[test]
|
||||||
|
fn clock_resync_acceptance_guard() {
|
||||||
|
// Generous connect RTT (10 ms): accept up to 1.5×.
|
||||||
|
assert!(accept_resync(14_000_000, 10_000_000));
|
||||||
|
assert!(!accept_resync(16_000_000, 10_000_000));
|
||||||
|
// Tiny connect RTT (200 µs, wired LAN): the 2 ms floor governs.
|
||||||
|
assert!(accept_resync(1_900_000, 200_000));
|
||||||
|
assert!(!accept_resync(2_100_000, 200_000));
|
||||||
|
// Boundary: exactly at the bound is accepted.
|
||||||
|
assert!(accept_resync(2_000_000, 0));
|
||||||
|
assert!(accept_resync(15_000_000, 10_000_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -782,3 +782,369 @@ impl ClipFetchHdr {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::config::Mode;
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconfigure_roundtrip() {
|
||||||
|
let rq = Reconfigure {
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 144,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq);
|
||||||
|
for accepted in [true, false] {
|
||||||
|
let rs = Reconfigured {
|
||||||
|
accepted,
|
||||||
|
mode: rq.mode,
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs);
|
||||||
|
}
|
||||||
|
// The type byte separates the post-handshake messages from each other.
|
||||||
|
assert!(Reconfigure::decode(
|
||||||
|
&Reconfigured {
|
||||||
|
accepted: true,
|
||||||
|
mode: rq.mode
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_keyframe_roundtrip() {
|
||||||
|
let bytes = RequestKeyframe.encode();
|
||||||
|
assert!(RequestKeyframe::decode(&bytes).is_ok());
|
||||||
|
// Distinct from the other control messages — its type byte must not collide.
|
||||||
|
let mode = Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&bytes).is_err());
|
||||||
|
// Length is exact (no trailing bytes accepted).
|
||||||
|
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfi_request_roundtrip() {
|
||||||
|
for (first_frame, last_frame) in [(0u32, 0u32), (40, 47), (5, 5), (1_000_000, u32::MAX)] {
|
||||||
|
let r = RfiRequest {
|
||||||
|
first_frame,
|
||||||
|
last_frame,
|
||||||
|
};
|
||||||
|
assert_eq!(RfiRequest::decode(&r.encode()).unwrap(), r);
|
||||||
|
}
|
||||||
|
// Disjoint from the bare keyframe request (its loss-unaware sibling) and others: type byte + length.
|
||||||
|
assert!(RfiRequest::decode(&RequestKeyframe.encode()).is_err());
|
||||||
|
assert!(RequestKeyframe::decode(
|
||||||
|
&RfiRequest {
|
||||||
|
first_frame: 1,
|
||||||
|
last_frame: 2
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
// Exact length — no trailing bytes.
|
||||||
|
let bytes = RfiRequest {
|
||||||
|
first_frame: 3,
|
||||||
|
last_frame: 9,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(RfiRequest::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(RfiRequest::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loss_report_roundtrip() {
|
||||||
|
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
|
||||||
|
let r = LossReport { loss_ppm };
|
||||||
|
assert_eq!(LossReport::decode(&r.encode()).unwrap(), r);
|
||||||
|
}
|
||||||
|
// Disjoint from the other control messages (type byte + length).
|
||||||
|
assert!(LossReport::decode(&RequestKeyframe.encode()).is_err());
|
||||||
|
assert!(RequestKeyframe::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||||
|
assert!(LossReport::decode(
|
||||||
|
&[LossReport { loss_ppm: 0 }.encode().as_slice(), &[0]].concat()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_loss_ppm_estimates_and_caps() {
|
||||||
|
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 0, 0), 0);
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 1000, 0), 0);
|
||||||
|
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
||||||
|
assert_eq!(window_loss_ppm(50, 0, 950, 0), 50_000);
|
||||||
|
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
||||||
|
assert_eq!(window_loss_ppm(50, 0, 950, 1), 100_000);
|
||||||
|
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 0, 3), 50_000);
|
||||||
|
assert!(window_loss_ppm(u64::MAX, 0, 1, 9) <= 1_000_000);
|
||||||
|
// Reordering: shards "recovered" early that then arrived are late, not lost — netted out, so
|
||||||
|
// a pure-reorder window reads 0. Partially late nets to the true loss (20 of 1000 = 2%).
|
||||||
|
assert_eq!(window_loss_ppm(50, 50, 1000, 0), 0);
|
||||||
|
assert_eq!(window_loss_ppm(50, 30, 980, 0), 20_000);
|
||||||
|
// `late` can outrun `recovered` across a window boundary (reorder straddling the report
|
||||||
|
// tick) or via a rare wire duplicate — saturate at a clean window, never underflow.
|
||||||
|
assert_eq!(window_loss_ppm(10, 25, 1000, 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitrate_messages_roundtrip() {
|
||||||
|
let req = SetBitrate {
|
||||||
|
bitrate_kbps: 14_000,
|
||||||
|
};
|
||||||
|
assert_eq!(SetBitrate::decode(&req.encode()).unwrap(), req);
|
||||||
|
let ack = BitrateChanged {
|
||||||
|
bitrate_kbps: 14_000,
|
||||||
|
};
|
||||||
|
assert_eq!(BitrateChanged::decode(&ack.encode()).unwrap(), ack);
|
||||||
|
// Same payload shape as LossReport — the type byte alone must keep them disjoint.
|
||||||
|
assert!(LossReport::decode(&req.encode()).is_err());
|
||||||
|
assert!(SetBitrate::decode(&ack.encode()).is_err());
|
||||||
|
assert!(BitrateChanged::decode(&req.encode()).is_err());
|
||||||
|
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_messages_roundtrip() {
|
||||||
|
let req = ProbeRequest {
|
||||||
|
target_kbps: 250_000,
|
||||||
|
duration_ms: 2000,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeRequest::decode(&req.encode()).unwrap(), req);
|
||||||
|
let res = ProbeResult {
|
||||||
|
bytes_sent: 62_500_000,
|
||||||
|
packets_sent: 480,
|
||||||
|
duration_ms: 2003,
|
||||||
|
wire_packets_sent: 41_000,
|
||||||
|
send_dropped: 1_200,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
|
||||||
|
assert_eq!(res.encode().len(), 29);
|
||||||
|
// A pre-wire-stats host's 21-byte ProbeResult still decodes, with the new fields zeroed.
|
||||||
|
let legacy = {
|
||||||
|
let full = res.encode();
|
||||||
|
full[..21].to_vec()
|
||||||
|
};
|
||||||
|
let decoded = ProbeResult::decode(&legacy).unwrap();
|
||||||
|
assert_eq!(decoded.wire_packets_sent, 0);
|
||||||
|
assert_eq!(decoded.send_dropped, 0);
|
||||||
|
assert_eq!(decoded.bytes_sent, res.bytes_sent);
|
||||||
|
// Type bytes keep the control messages disjoint from each other.
|
||||||
|
assert!(ProbeRequest::decode(&res.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&req.encode()).is_err());
|
||||||
|
assert!(ProbeResult::decode(&req.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_messages_roundtrip() {
|
||||||
|
let probe = ClockProbe {
|
||||||
|
t1_ns: 1_700_000_000_123,
|
||||||
|
};
|
||||||
|
assert_eq!(ClockProbe::decode(&probe.encode()).unwrap(), probe);
|
||||||
|
let echo = ClockEcho {
|
||||||
|
t1_ns: 1_700_000_000_123,
|
||||||
|
t2_ns: 1_700_000_050_456,
|
||||||
|
t3_ns: 1_700_000_050_789,
|
||||||
|
};
|
||||||
|
assert_eq!(ClockEcho::decode(&echo.encode()).unwrap(), echo);
|
||||||
|
// Disjoint from the other control messages (distinct type bytes).
|
||||||
|
assert!(ClockProbe::decode(&echo.encode()).is_err());
|
||||||
|
assert!(ProbeRequest::decode(&probe.encode()).is_err());
|
||||||
|
assert!(ClockEcho::decode(&probe.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Shared clipboard control + fetch-stream message codecs (0x40-0x44) -----------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_control_roundtrip() {
|
||||||
|
for (enabled, flags) in [
|
||||||
|
(true, 0u8),
|
||||||
|
(false, 0),
|
||||||
|
(true, CLIP_FLAG_FILES),
|
||||||
|
(false, 0xFF),
|
||||||
|
] {
|
||||||
|
let m = ClipControl { enabled, flags };
|
||||||
|
assert_eq!(ClipControl::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// Disjoint from its host→client sibling (type byte + length) and exact length.
|
||||||
|
assert!(ClipControl::decode(
|
||||||
|
&ClipState {
|
||||||
|
enabled: true,
|
||||||
|
policy: 0,
|
||||||
|
reason: 0
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
let bytes = ClipControl {
|
||||||
|
enabled: true,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(ClipControl::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(ClipControl::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_state_roundtrip() {
|
||||||
|
let cases = [
|
||||||
|
ClipState {
|
||||||
|
enabled: true,
|
||||||
|
policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES,
|
||||||
|
reason: CLIP_REASON_OK,
|
||||||
|
},
|
||||||
|
ClipState {
|
||||||
|
enabled: false,
|
||||||
|
policy: 0,
|
||||||
|
reason: CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||||
|
},
|
||||||
|
ClipState {
|
||||||
|
enabled: true,
|
||||||
|
policy: CLIP_POLICY_TEXT,
|
||||||
|
reason: CLIP_REASON_NO_FILES,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in cases {
|
||||||
|
assert_eq!(ClipState::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// A ClipControl must not decode as a ClipState (type byte).
|
||||||
|
assert!(ClipState::decode(
|
||||||
|
&ClipControl {
|
||||||
|
enabled: true,
|
||||||
|
flags: 0
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
let bytes = cases[0].encode();
|
||||||
|
assert!(ClipState::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_offer_roundtrip() {
|
||||||
|
// Empty offer, one kind, and a full multi-format offer (text/rich/image/files).
|
||||||
|
let cases = [
|
||||||
|
ClipOffer {
|
||||||
|
seq: 0,
|
||||||
|
kinds: vec![],
|
||||||
|
},
|
||||||
|
ClipOffer {
|
||||||
|
seq: 1,
|
||||||
|
kinds: vec![ClipKind {
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
size_hint: 12,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
ClipOffer {
|
||||||
|
seq: u32::MAX,
|
||||||
|
kinds: vec![
|
||||||
|
ClipKind {
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
size_hint: 0,
|
||||||
|
},
|
||||||
|
ClipKind {
|
||||||
|
mime: "text/html".into(),
|
||||||
|
size_hint: 4096,
|
||||||
|
},
|
||||||
|
ClipKind {
|
||||||
|
mime: "image/png".into(),
|
||||||
|
size_hint: 1 << 30,
|
||||||
|
},
|
||||||
|
ClipKind {
|
||||||
|
mime: "application/x-punktfunk-files".into(),
|
||||||
|
size_hint: 5_000_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in &cases {
|
||||||
|
assert_eq!(&ClipOffer::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// Trailing bytes are rejected (get_clip_kind consumes exactly to the end).
|
||||||
|
let mut padded = cases[1].encode();
|
||||||
|
padded.push(0);
|
||||||
|
assert!(ClipOffer::decode(&padded).is_err());
|
||||||
|
// A count byte over the cap is rejected before allocating.
|
||||||
|
let mut over = cases[0].encode();
|
||||||
|
over[9] = (CLIP_MAX_KINDS + 1) as u8;
|
||||||
|
assert!(ClipOffer::decode(&over).is_err());
|
||||||
|
// Disjoint from a same-family control message.
|
||||||
|
assert!(ClipOffer::decode(
|
||||||
|
&ClipControl {
|
||||||
|
enabled: true,
|
||||||
|
flags: 0
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_fetch_roundtrip() {
|
||||||
|
let cases = [
|
||||||
|
ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
},
|
||||||
|
ClipFetch {
|
||||||
|
seq: 7,
|
||||||
|
file_index: 0,
|
||||||
|
mime: "application/x-punktfunk-files".into(),
|
||||||
|
},
|
||||||
|
ClipFetch {
|
||||||
|
seq: u32::MAX,
|
||||||
|
file_index: 41,
|
||||||
|
mime: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in &cases {
|
||||||
|
assert_eq!(&ClipFetch::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// Trailing + truncation both rejected (exact-length mime check).
|
||||||
|
let bytes = cases[0].encode();
|
||||||
|
assert!(ClipFetch::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(ClipFetch::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
// A fetch-stream message must not decode as a control-stream offer, and vice-versa.
|
||||||
|
assert!(ClipOffer::decode(&cases[0].encode()).is_err());
|
||||||
|
assert!(ClipFetch::decode(
|
||||||
|
&ClipOffer {
|
||||||
|
seq: 1,
|
||||||
|
kinds: vec![]
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_fetch_hdr_roundtrip() {
|
||||||
|
for (status, total_size) in [
|
||||||
|
(CLIP_FETCH_OK, 15u64),
|
||||||
|
(CLIP_FETCH_STALE, 0),
|
||||||
|
(CLIP_FETCH_UNAVAILABLE, 0),
|
||||||
|
(CLIP_FETCH_DENIED, 0),
|
||||||
|
(CLIP_FETCH_OK, u64::MAX),
|
||||||
|
] {
|
||||||
|
let m = ClipFetchHdr { status, total_size };
|
||||||
|
assert_eq!(ClipFetchHdr::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
let bytes = ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: 1,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -605,3 +605,321 @@ pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
|
|||||||
stages,
|
stages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hdr_meta_datagram_roundtrip_and_truncation() {
|
||||||
|
let m = HdrMeta {
|
||||||
|
// BT.2020 display primaries in 1/50000 units (the DXGI/ST.2086 reference values).
|
||||||
|
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
||||||
|
white_point: [15635, 16450], // D65
|
||||||
|
max_display_mastering_luminance: 10_000_000, // 1000 nits in 0.0001 cd/m²
|
||||||
|
min_display_mastering_luminance: 1, // 0.0001 nits
|
||||||
|
max_cll: 1000,
|
||||||
|
max_fall: 400,
|
||||||
|
};
|
||||||
|
let d = encode_hdr_meta_datagram(&m);
|
||||||
|
assert_eq!(d[0], HDR_META_MAGIC);
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&d), Some(m));
|
||||||
|
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||||
|
for n in 0..d.len() {
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
let mut bad = d.clone();
|
||||||
|
bad[0] = HIDOUT_MAGIC;
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&bad), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_timing_datagram_roundtrip_and_truncation() {
|
||||||
|
let t = HostTiming {
|
||||||
|
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
|
||||||
|
host_us: 4_321,
|
||||||
|
stages: None,
|
||||||
|
};
|
||||||
|
let d = encode_host_timing_datagram(&t);
|
||||||
|
assert_eq!(d[0], HOST_TIMING_MAGIC);
|
||||||
|
assert_eq!(d.len(), 13);
|
||||||
|
assert_eq!(decode_host_timing_datagram(&d), Some(t));
|
||||||
|
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||||
|
for n in 0..d.len() {
|
||||||
|
assert_eq!(decode_host_timing_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
let mut bad = d.clone();
|
||||||
|
bad[0] = HDR_META_MAGIC;
|
||||||
|
assert_eq!(decode_host_timing_datagram(&bad), None);
|
||||||
|
|
||||||
|
// Extended form (T0.1): the stage tail roundtrips; a truncated tail (an old host's 13-byte
|
||||||
|
// datagram, or anything short of the full 25) degrades to `stages: None`, never a partial
|
||||||
|
// read; the prefix fields stay identical in both forms (the append-extensibility contract).
|
||||||
|
let ts = HostTiming {
|
||||||
|
stages: Some(HostStages {
|
||||||
|
queue_us: 900,
|
||||||
|
encode_us: 3_100,
|
||||||
|
pace_us: 2_500,
|
||||||
|
}),
|
||||||
|
..t
|
||||||
|
};
|
||||||
|
let ds = encode_host_timing_datagram(&ts);
|
||||||
|
assert_eq!(ds.len(), 25);
|
||||||
|
assert_eq!(
|
||||||
|
&ds[..13],
|
||||||
|
&d[..13],
|
||||||
|
"prefix is byte-identical to the legacy form"
|
||||||
|
);
|
||||||
|
assert_eq!(decode_host_timing_datagram(&ds), Some(ts));
|
||||||
|
for n in 13..ds.len() {
|
||||||
|
assert_eq!(
|
||||||
|
decode_host_timing_datagram(&ds[..n]),
|
||||||
|
Some(t),
|
||||||
|
"partial stage tail ({n} B) must degrade to the legacy decode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_datagram_roundtrip() {
|
||||||
|
let opus = [0x42u8; 97];
|
||||||
|
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
|
||||||
|
assert_eq!(d[0], AUDIO_MAGIC);
|
||||||
|
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
|
||||||
|
assert_eq!((seq, pts), (7, 1_000_000_123));
|
||||||
|
assert_eq!(payload, opus);
|
||||||
|
assert!(decode_audio_datagram(&d[..12]).is_none()); // truncated header
|
||||||
|
assert!(decode_audio_datagram(&[0u8; 13]).is_none()); // bad magic
|
||||||
|
|
||||||
|
// Empty payload is legal (DTX) — header-only datagram.
|
||||||
|
let header_only = encode_audio_datagram(0, 0, &[]);
|
||||||
|
let (_, _, empty) = decode_audio_datagram(&header_only).unwrap();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumble_datagram_roundtrip() {
|
||||||
|
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
|
||||||
|
assert_eq!(d[0], RUMBLE_MAGIC);
|
||||||
|
assert_eq!(decode_rumble_datagram(&d), Some((1, 0x1234, 0xFFFF)));
|
||||||
|
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
|
||||||
|
// v2 envelope round-trips seq + ttl.
|
||||||
|
let d = encode_rumble_datagram_v2(2, 0x4000, 0x8000, 7, 400);
|
||||||
|
assert_eq!(d[0], RUMBLE_MAGIC);
|
||||||
|
assert_eq!(d.len(), RUMBLE_V2_LEN);
|
||||||
|
assert_eq!(
|
||||||
|
decode_rumble_envelope(&d),
|
||||||
|
Some(RumbleUpdate {
|
||||||
|
pad: 2,
|
||||||
|
low: 0x4000,
|
||||||
|
high: 0x8000,
|
||||||
|
envelope: Some(RumbleEnvelope {
|
||||||
|
seq: 7,
|
||||||
|
ttl_ms: 400
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// The legacy level decoder reads a v2 datagram as a plain level — the tail is ignored, so an
|
||||||
|
// old client running against a new host still renders the right amplitudes.
|
||||||
|
assert_eq!(decode_rumble_datagram(&d), Some((2, 0x4000, 0x8000)));
|
||||||
|
|
||||||
|
// A legacy 7-byte datagram (old host) decodes as a level with no envelope — a new client then
|
||||||
|
// applies its own staleness policy.
|
||||||
|
let v1 = encode_rumble_datagram(3, 0x1111, 0x2222);
|
||||||
|
assert_eq!(
|
||||||
|
decode_rumble_envelope(&v1),
|
||||||
|
Some(RumbleUpdate {
|
||||||
|
pad: 3,
|
||||||
|
low: 0x1111,
|
||||||
|
high: 0x2222,
|
||||||
|
envelope: None,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// A torn/short tail (8 or 9 bytes) is not a valid envelope — degrade to a level, never panic
|
||||||
|
// or drop. (The host never emits these; a truncating middlebox might.)
|
||||||
|
assert_eq!(
|
||||||
|
decode_rumble_envelope(&d[..8]).map(|u| u.envelope),
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_rumble_envelope(&d[..9]).map(|u| u.envelope),
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bad tag / too short → None on both decoders.
|
||||||
|
assert!(decode_rumble_envelope(&d[..6]).is_none());
|
||||||
|
let mut wrong_tag = d;
|
||||||
|
wrong_tag[0] = AUDIO_MAGIC;
|
||||||
|
assert!(decode_rumble_envelope(&wrong_tag).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumble_envelope_seq_gate_drops_reordered_stale_start() {
|
||||||
|
use crate::input::GamepadSnapshot;
|
||||||
|
// The client-side reorder gate (reused verbatim from gamepad snapshots): a stale start
|
||||||
|
// arriving after a stop must not re-light the motors.
|
||||||
|
let stop = decode_rumble_envelope(&encode_rumble_datagram_v2(0, 0, 0, 10, 0)).unwrap();
|
||||||
|
let stale_start =
|
||||||
|
decode_rumble_envelope(&encode_rumble_datagram_v2(0, 0x8000, 0x8000, 9, 400)).unwrap();
|
||||||
|
let stop_seq = stop.envelope.unwrap().seq;
|
||||||
|
let stale_seq = stale_start.envelope.unwrap().seq;
|
||||||
|
// Nothing applied yet → the first update always passes.
|
||||||
|
assert!(GamepadSnapshot::seq_newer(stop_seq, None));
|
||||||
|
// The reordered older start does NOT supersede the stop.
|
||||||
|
assert!(!GamepadSnapshot::seq_newer(stale_seq, Some(stop_seq)));
|
||||||
|
// A genuine later renewal does.
|
||||||
|
assert!(GamepadSnapshot::seq_newer(11, Some(stop_seq)));
|
||||||
|
// Wraps: seq 1 supersedes 254.
|
||||||
|
assert!(GamepadSnapshot::seq_newer(1, Some(254)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mic_datagram_roundtrip_and_disjoint_from_audio() {
|
||||||
|
let opus = [0x5Au8; 80];
|
||||||
|
let d = encode_mic_datagram(42, 9_999, &opus);
|
||||||
|
assert_eq!(d[0], MIC_MAGIC);
|
||||||
|
let (seq, pts, payload) = decode_mic_datagram(&d).unwrap();
|
||||||
|
assert_eq!((seq, pts), (42, 9_999));
|
||||||
|
assert_eq!(payload, opus);
|
||||||
|
assert!(decode_mic_datagram(&d[..12]).is_none()); // truncated
|
||||||
|
// Tag separation: a mic datagram is not an audio datagram and vice-versa.
|
||||||
|
assert!(decode_audio_datagram(&d).is_none());
|
||||||
|
assert!(decode_mic_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||||
|
// Empty payload (DTX) is legal.
|
||||||
|
assert!(decode_mic_datagram(&encode_mic_datagram(0, 0, &[]))
|
||||||
|
.unwrap()
|
||||||
|
.2
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_input_roundtrip() {
|
||||||
|
for ev in [
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 1,
|
||||||
|
finger: 0,
|
||||||
|
active: true,
|
||||||
|
x: 40000,
|
||||||
|
y: 12345,
|
||||||
|
},
|
||||||
|
RichInput::Motion {
|
||||||
|
pad: 0,
|
||||||
|
gyro: [-100, 200, -300],
|
||||||
|
accel: [16384, -8192, 1],
|
||||||
|
},
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 2,
|
||||||
|
surface: 1,
|
||||||
|
finger: 1,
|
||||||
|
touch: true,
|
||||||
|
click: false,
|
||||||
|
x: -12345,
|
||||||
|
y: 30000,
|
||||||
|
pressure: 4000,
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let d = ev.encode();
|
||||||
|
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||||
|
assert_eq!(RichInput::decode(&d), Some(ev));
|
||||||
|
}
|
||||||
|
// A raw Triton state report rides the plane verbatim (as-is SC2 passthrough).
|
||||||
|
let mut data = [0u8; HID_REPORT_MAX];
|
||||||
|
data[0] = 0x42; // ID_TRITON_CONTROLLER_STATE
|
||||||
|
for (i, b) in data.iter_mut().enumerate().take(46).skip(1) {
|
||||||
|
*b = i as u8;
|
||||||
|
}
|
||||||
|
let raw = RichInput::HidReport {
|
||||||
|
pad: 3,
|
||||||
|
len: 46,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let d = raw.encode();
|
||||||
|
assert_eq!(d.len(), 4 + 46); // tag + kind + pad + len + body — no fixed-array padding
|
||||||
|
assert_eq!(RichInput::decode(&d), Some(raw));
|
||||||
|
// A torn HidReport truncates to what arrived rather than over-reading (len clamps).
|
||||||
|
assert_eq!(
|
||||||
|
RichInput::decode(&d[..20]),
|
||||||
|
Some(RichInput::HidReport {
|
||||||
|
pad: 3,
|
||||||
|
len: 16,
|
||||||
|
data: {
|
||||||
|
let mut t = [0u8; HID_REPORT_MAX];
|
||||||
|
t[..16].copy_from_slice(&data[..16]);
|
||||||
|
t
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||||
|
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
|
||||||
|
// short
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hid_output_roundtrip() {
|
||||||
|
let cases = [
|
||||||
|
HidOutput::Led {
|
||||||
|
pad: 2,
|
||||||
|
r: 0xAA,
|
||||||
|
g: 0xBB,
|
||||||
|
b: 0xCC,
|
||||||
|
},
|
||||||
|
HidOutput::PlayerLeds {
|
||||||
|
pad: 0,
|
||||||
|
bits: 0b10101,
|
||||||
|
},
|
||||||
|
HidOutput::Trigger {
|
||||||
|
pad: 1,
|
||||||
|
which: 1,
|
||||||
|
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||||
|
},
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad: 0,
|
||||||
|
side: 1,
|
||||||
|
amplitude: 0x1234,
|
||||||
|
period: 0x5678,
|
||||||
|
count: 9,
|
||||||
|
},
|
||||||
|
// A raw Triton rumble output report (as-is SC2 passthrough, host→client).
|
||||||
|
HidOutput::HidRaw {
|
||||||
|
pad: 1,
|
||||||
|
kind: HID_RAW_OUTPUT,
|
||||||
|
data: vec![0x80, 0, 0, 0, 0x34, 0x12, 0, 0x78, 0x56, 0],
|
||||||
|
},
|
||||||
|
// A raw 64-byte feature report (lizard-off / IMU-enable settings write).
|
||||||
|
HidOutput::HidRaw {
|
||||||
|
pad: 0,
|
||||||
|
kind: HID_RAW_FEATURE,
|
||||||
|
data: {
|
||||||
|
let mut f = vec![0u8; HID_REPORT_MAX];
|
||||||
|
f[0] = 1; // Triton feature reports ride report id 1
|
||||||
|
f[1] = 0x87; // ID_SET_SETTINGS_VALUES
|
||||||
|
f
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for ev in &cases {
|
||||||
|
let d = ev.encode();
|
||||||
|
assert_eq!(d[0], HIDOUT_MAGIC);
|
||||||
|
assert_eq!(HidOutput::decode(&d).as_ref(), Some(ev));
|
||||||
|
}
|
||||||
|
assert!(HidOutput::decode(&[HIDOUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||||
|
// A rich-input datagram is not a HID-output datagram.
|
||||||
|
assert!(HidOutput::decode(
|
||||||
|
&RichInput::Motion {
|
||||||
|
pad: 0,
|
||||||
|
gyro: [0; 3],
|
||||||
|
accel: [0; 3]
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ fn stream_transport() -> Arc<quinn::TransportConfig> {
|
|||||||
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
|
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
|
||||||
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
|
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
|
||||||
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
|
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
|
||||||
/// the keep-alive holds it open through quiet control periods.
|
/// the keep-alive holds it open through quiet control periods. Clamped to a 1 s..1 h window:
|
||||||
|
/// the ceiling keeps an absurd operator-supplied value inside QUIC's VarInt millisecond range,
|
||||||
|
/// so the conversion below genuinely cannot fail (it used to panic host startup instead).
|
||||||
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
|
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
let idle = idle.max(Duration::from_secs(1));
|
let idle = idle.clamp(Duration::from_secs(1), Duration::from_secs(3600));
|
||||||
let keep_alive = (idle / 2).min(Duration::from_secs(4));
|
let keep_alive = (idle / 2).min(Duration::from_secs(4));
|
||||||
let mut t = quinn::TransportConfig::default();
|
let mut t = quinn::TransportConfig::default();
|
||||||
t.max_idle_timeout(Some(
|
t.max_idle_timeout(Some(
|
||||||
@@ -293,3 +295,25 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
|||||||
.supported_schemes()
|
.supported_schemes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::endpoint;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_is_sha256_of_der() {
|
||||||
|
// Stable across calls, distinct for distinct certs.
|
||||||
|
let a = endpoint::cert_fingerprint(b"cert-a");
|
||||||
|
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||||
|
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absurd_idle_timeout_is_clamped_not_a_panic() {
|
||||||
|
// The conversion to quinn's IdleTimeout fails past the QUIC VarInt millisecond
|
||||||
|
// ceiling — an operator-supplied huge PUNKTFUNK_IDLE_TIMEOUT_MS used to panic host
|
||||||
|
// startup through the `expect`. Both extremes must construct.
|
||||||
|
let _ = super::stream_transport_idle(std::time::Duration::MAX);
|
||||||
|
let _ = super::stream_transport_idle(std::time::Duration::ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -182,8 +182,9 @@ pub struct Start {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Truncate `s` to at most `max` bytes on a UTF-8 char boundary (so a multi-byte char straddling
|
/// Truncate `s` to at most `max` bytes on a UTF-8 char boundary (so a multi-byte char straddling
|
||||||
/// the cap is dropped whole, never split). Shared by Hello's length-prefixed name/launch fields.
|
/// the cap is dropped whole, never split). Shared by Hello's length-prefixed name/launch fields
|
||||||
fn truncate_to(s: &str, max: usize) -> &str {
|
/// and [`PairRequest`](super::PairRequest)'s copy of the same name cap.
|
||||||
|
pub(super) fn truncate_to(s: &str, max: usize) -> &str {
|
||||||
if s.len() <= max {
|
if s.len() <= max {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -495,3 +496,601 @@ impl Start {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode, Role};
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn welcome_roundtrip() {
|
||||||
|
let w = Welcome {
|
||||||
|
abi_version: 1,
|
||||||
|
udp_port: 9999,
|
||||||
|
mode: Mode {
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
refresh_hz: 240,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [7u8; 16],
|
||||||
|
salt: [1, 2, 3, 4],
|
||||||
|
frames: 600,
|
||||||
|
compositor: CompositorPref::Gamescope,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 50_000,
|
||||||
|
bit_depth: 10,
|
||||||
|
color: ColorInfo::HDR10_BT2020_PQ,
|
||||||
|
chroma_format: CHROMA_IDC_444,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
};
|
||||||
|
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||||
|
|
||||||
|
// Client-side reassembler ceiling derives from the negotiated rate: 4x the average frame at
|
||||||
|
// 50 Mbps/240 Hz is ~104 KB, so the 8 MiB floor governs. The host keeps the p1_defaults
|
||||||
|
// bound (it never reassembles video), as does a client of a bitrate-0 (older) host.
|
||||||
|
let cc = w.session_config(Role::Client);
|
||||||
|
assert_eq!(cc.max_frame_bytes, 8 << 20);
|
||||||
|
cc.validate().expect("derived client config validates");
|
||||||
|
assert_eq!(w.session_config(Role::Host).max_frame_bytes, 64 << 20);
|
||||||
|
let old_host = Welcome {
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
..w
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
old_host.session_config(Role::Client).max_frame_bytes,
|
||||||
|
64 << 20
|
||||||
|
);
|
||||||
|
// A high-rate mode scales past the floor: 1.5 Gbps at 60 Hz = 4 x 3.125 MB = 12.5 MB.
|
||||||
|
let fat = Welcome {
|
||||||
|
bitrate_kbps: 1_500_000,
|
||||||
|
mode: Mode {
|
||||||
|
width: 5120,
|
||||||
|
height: 1440,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
..w
|
||||||
|
};
|
||||||
|
let derived = fat.session_config(Role::Client).max_frame_bytes;
|
||||||
|
assert_eq!(derived, 4 * 1_500_000 * 125 / 60);
|
||||||
|
assert!(derived > (8 << 20) && derived < (64 << 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_negotiation_and_back_compat() {
|
||||||
|
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264, 0),
|
||||||
|
Some(CODEC_AV1)
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_codec(CODEC_H264, CODEC_H264, 0), Some(CODEC_H264));
|
||||||
|
// A software host (H.264 only) + an HEVC-only client share nothing → refuse.
|
||||||
|
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, 0), None);
|
||||||
|
// An older client (0 = no codec byte) is treated as HEVC-only.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(0, CODEC_HEVC | CODEC_H264, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_codec(0, CODEC_H264, 0), None);
|
||||||
|
|
||||||
|
// Soft preference: honored when the host can also emit it, overriding precedence...
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_H264 | CODEC_HEVC, CODEC_H264),
|
||||||
|
Some(CODEC_H264)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_AV1, CODEC_HEVC | CODEC_AV1, CODEC_AV1),
|
||||||
|
Some(CODEC_AV1)
|
||||||
|
);
|
||||||
|
// ...but falls back to precedence when the preferred codec isn't in the shared set.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_H264, CODEC_HEVC | CODEC_H264, CODEC_AV1),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
// A preference the host can't emit still can't rescue a no-shared-codec case.
|
||||||
|
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
|
||||||
|
|
||||||
|
// PyroWave is opt-in ONLY (plan §3): mutual support NEVER auto-selects it — the ladder
|
||||||
|
// ignores it entirely...
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_PYROWAVE, CODEC_HEVC | CODEC_PYROWAVE, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
// ...even when it is the ONLY shared codec (an all-intra 200 Mbps stream must never be a
|
||||||
|
// silent fallback)...
|
||||||
|
assert_eq!(resolve_codec(CODEC_PYROWAVE, CODEC_PYROWAVE, 0), None);
|
||||||
|
// ...it is reachable exclusively through the client's explicit preference.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(
|
||||||
|
CODEC_HEVC | CODEC_PYROWAVE,
|
||||||
|
CODEC_HEVC | CODEC_PYROWAVE,
|
||||||
|
CODEC_PYROWAVE
|
||||||
|
),
|
||||||
|
Some(CODEC_PYROWAVE)
|
||||||
|
);
|
||||||
|
// A pyrowave preference against a host without the backend falls back to the ladder.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_PYROWAVE, CODEC_HEVC, CODEC_PYROWAVE),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
// And the negotiated bit SURVIVES the Welcome wire roundtrip — the decode whitelist
|
||||||
|
// once folded unknown codec bytes (incl. PyroWave) to HEVC, which sent wavelet AUs
|
||||||
|
// into an FFmpeg HEVC decoder on the first on-glass run.
|
||||||
|
let mut pw_w = Welcome::decode(
|
||||||
|
&Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 1,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 0,
|
||||||
|
max_data_per_block: 1024,
|
||||||
|
},
|
||||||
|
shard_payload: 1024,
|
||||||
|
encrypt: false,
|
||||||
|
key: [0; 16],
|
||||||
|
salt: [0; 4],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_PYROWAVE,
|
||||||
|
host_caps: 0,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pw_w.codec, CODEC_PYROWAVE);
|
||||||
|
// A genuinely unknown future bit still folds to the HEVC default.
|
||||||
|
pw_w.codec = 0x40;
|
||||||
|
assert_eq!(Welcome::decode(&pw_w.encode()).unwrap().codec, CODEC_HEVC);
|
||||||
|
|
||||||
|
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
|
||||||
|
// a build that ignores the trailing byte (back-compat: extra bytes are skipped).
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
|
||||||
|
video_codecs: CODEC_H264 | CODEC_HEVC,
|
||||||
|
preferred_codec: CODEC_H264,
|
||||||
|
display_hdr: None,
|
||||||
|
};
|
||||||
|
let enc = h.encode();
|
||||||
|
let dec = Hello::decode(&enc).unwrap();
|
||||||
|
assert_eq!(dec.video_codecs, CODEC_H264 | CODEC_HEVC);
|
||||||
|
assert_eq!(dec.preferred_codec, CODEC_H264);
|
||||||
|
// Drop the preferred_codec byte → still decodes, video_codecs intact, preference gone.
|
||||||
|
let no_pref = &enc[..enc.len() - 1];
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(no_pref).unwrap().video_codecs,
|
||||||
|
CODEC_H264 | CODEC_HEVC
|
||||||
|
);
|
||||||
|
assert_eq!(Hello::decode(no_pref).unwrap().preferred_codec, 0);
|
||||||
|
// A pre-codec Hello (no video_codecs/preferred bytes) decodes to 0 → HEVC-only.
|
||||||
|
let legacy = &enc[..enc.len() - 2];
|
||||||
|
assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0);
|
||||||
|
assert_eq!(Hello::decode(legacy).unwrap().preferred_codec, 0);
|
||||||
|
|
||||||
|
// A pre-codec Welcome (no codec byte) decodes to HEVC.
|
||||||
|
let mut w = Welcome::decode(
|
||||||
|
&Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 1,
|
||||||
|
mode: h.mode,
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 0,
|
||||||
|
max_data_per_block: 1024,
|
||||||
|
},
|
||||||
|
shard_payload: 1024,
|
||||||
|
encrypt: false,
|
||||||
|
key: [0; 16],
|
||||||
|
salt: [0; 4],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_H264,
|
||||||
|
host_caps: 0,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(w.codec, CODEC_H264);
|
||||||
|
w.codec = CODEC_HEVC;
|
||||||
|
let wenc = w.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc[..wenc.len() - 1]).unwrap().codec,
|
||||||
|
CODEC_HEVC
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_start_roundtrip() {
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 1,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 120,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Kwin,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 25_000,
|
||||||
|
name: Some("Test Device".into()),
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
|
video_caps: VIDEO_CAP_10BIT,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
|
||||||
|
preferred_codec: CODEC_HEVC,
|
||||||
|
display_hdr: None,
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
|
let s = Start {
|
||||||
|
client_udp_port: 1234,
|
||||||
|
};
|
||||||
|
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_welcome_compositor_back_compat() {
|
||||||
|
// Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's
|
||||||
|
// shorter message still decodes (missing fields = Auto), and a legacy peer reading a
|
||||||
|
// new message ignores the trailing bytes. Simulate both directions by truncation.
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Mutter,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 80_000,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
display_hdr: None,
|
||||||
|
};
|
||||||
|
let enc = h.encode();
|
||||||
|
assert_eq!(enc.len(), 26);
|
||||||
|
// Legacy (20-byte) Hello → both Auto, no bitrate, mode intact.
|
||||||
|
let legacy = Hello::decode(&enc[..20]).unwrap();
|
||||||
|
assert_eq!(legacy.compositor, CompositorPref::Auto);
|
||||||
|
assert_eq!(legacy.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy.bitrate_kbps, 0);
|
||||||
|
assert_eq!(legacy.mode, h.mode);
|
||||||
|
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
|
||||||
|
let mid = Hello::decode(&enc[..21]).unwrap();
|
||||||
|
assert_eq!(mid.compositor, CompositorPref::Mutter);
|
||||||
|
assert_eq!(mid.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (22-byte) Hello → compositor + gamepad intact, bitrate 0 (host default).
|
||||||
|
let pre_bitrate = Hello::decode(&enc[..22]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate.gamepad, GamepadPref::DualSense);
|
||||||
|
assert_eq!(pre_bitrate.bitrate_kbps, 0);
|
||||||
|
// Full message → bitrate intact.
|
||||||
|
assert_eq!(Hello::decode(&enc).unwrap().bitrate_kbps, 80_000);
|
||||||
|
|
||||||
|
let w = Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 7000,
|
||||||
|
mode: h.mode,
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [3u8; 16],
|
||||||
|
salt: [9, 8, 7, 6],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Kwin,
|
||||||
|
gamepad: GamepadPref::Xbox360,
|
||||||
|
bitrate_kbps: 120_000,
|
||||||
|
bit_depth: 10,
|
||||||
|
color: ColorInfo::HDR10_BT2020_PQ,
|
||||||
|
chroma_format: CHROMA_IDC_444,
|
||||||
|
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||||
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
};
|
||||||
|
let wenc = w.encode();
|
||||||
|
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||||
|
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||||
|
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||||
|
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(legacy_w.frames, 0);
|
||||||
|
assert_eq!(legacy_w.key, w.key);
|
||||||
|
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
|
||||||
|
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
|
||||||
|
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (55-byte) Welcome → gamepad intact, bitrate 0 (unknown).
|
||||||
|
let pre_bitrate_w = Welcome::decode(&wenc[..55]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate_w.gamepad, GamepadPref::Xbox360);
|
||||||
|
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(pre_bitrate_w.bit_depth, 8); // older host (no trailing byte) → 8-bit assumed
|
||||||
|
assert_eq!(legacy_w.bit_depth, 8);
|
||||||
|
// A pre-colour (60-byte) Welcome → SDR BT.709 (the only colour those hosts produced).
|
||||||
|
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
|
||||||
|
assert_eq!(pre_color_w.bit_depth, 10);
|
||||||
|
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
|
||||||
|
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
|
||||||
|
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||||
|
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
|
||||||
|
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
|
||||||
|
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
|
||||||
|
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
|
||||||
|
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
|
||||||
|
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
|
||||||
|
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
|
||||||
|
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
|
||||||
|
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
|
||||||
|
assert_eq!(pre_audio_w.audio_channels, 2);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().color,
|
||||||
|
ColorInfo::HDR10_BT2020_PQ
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().chroma_format,
|
||||||
|
CHROMA_IDC_444
|
||||||
|
); // full form carries 4:4:4
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||||
|
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
|
||||||
|
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().host_caps,
|
||||||
|
HOST_CAP_GAMEPAD_STATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_name_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: Some("Enrico's MacBook".into()),
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
display_hdr: None,
|
||||||
|
};
|
||||||
|
let enc = base.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&enc).unwrap().name.as_deref(),
|
||||||
|
Some("Enrico's MacBook")
|
||||||
|
);
|
||||||
|
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
|
||||||
|
// host reading a bitrate-era Hello decodes name = None.
|
||||||
|
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
|
||||||
|
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
|
||||||
|
let unnamed = Hello {
|
||||||
|
name: None,
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(unnamed.encode().len(), 26);
|
||||||
|
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
|
||||||
|
let long = Hello {
|
||||||
|
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode()).unwrap();
|
||||||
|
let n = dec.name.expect("truncated name still present");
|
||||||
|
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
|
||||||
|
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
|
||||||
|
let mut bad_len = unnamed.encode();
|
||||||
|
bad_len.push(40); // claims 40 name bytes, none follow
|
||||||
|
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
|
||||||
|
let mut bad_utf8 = unnamed.encode();
|
||||||
|
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
|
||||||
|
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_launch_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
display_hdr: None,
|
||||||
|
};
|
||||||
|
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||||
|
let with_launch = Hello {
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch);
|
||||||
|
// launch + name together.
|
||||||
|
let both = Hello {
|
||||||
|
name: Some("Enrico's Mac".into()),
|
||||||
|
launch: Some("custom:abc123".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
|
||||||
|
// name but no launch (a name-era client): launch decodes None.
|
||||||
|
let name_only = Hello {
|
||||||
|
name: Some("Enrico's Mac".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None);
|
||||||
|
// Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted).
|
||||||
|
assert_eq!(base.encode().len(), 26);
|
||||||
|
assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None);
|
||||||
|
// A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it.
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&with_launch.encode()[..26]).unwrap().launch,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX.
|
||||||
|
let long = Hello {
|
||||||
|
launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode())
|
||||||
|
.unwrap()
|
||||||
|
.launch
|
||||||
|
.expect("present");
|
||||||
|
assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_display_hdr_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
refresh_hz: 120,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: VIDEO_CAP_10BIT | VIDEO_CAP_HDR,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
display_hdr: None,
|
||||||
|
};
|
||||||
|
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
|
||||||
|
let vol = HdrMeta {
|
||||||
|
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]], // G, B, R
|
||||||
|
white_point: [15635, 16450], // D65
|
||||||
|
max_display_mastering_luminance: 8_000_000, // 800 nits
|
||||||
|
min_display_mastering_luminance: 500, // 0.05 nits
|
||||||
|
max_cll: 0,
|
||||||
|
max_fall: 400,
|
||||||
|
};
|
||||||
|
let with_hdr = Hello {
|
||||||
|
display_hdr: Some(vol),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
// Full roundtrip, including the forced placeholders for the earlier trailing fields.
|
||||||
|
assert_eq!(Hello::decode(&with_hdr.encode()).unwrap(), with_hdr);
|
||||||
|
// display_hdr alone (every earlier optional at its default) still lands at a deterministic
|
||||||
|
// offset — the placeholder discipline holds through the whole tail.
|
||||||
|
let hdr_only = Hello {
|
||||||
|
video_caps: 0,
|
||||||
|
display_hdr: Some(vol),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&hdr_only.encode()).unwrap(), hdr_only);
|
||||||
|
// An older host reading a display_hdr-bearing Hello ignores the trailing block (its decode
|
||||||
|
// stops at preferred_codec); a new host reading an older client's Hello gets None.
|
||||||
|
let enc = with_hdr.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&enc[..enc.len() - HDR_META_BODY_LEN]).unwrap(),
|
||||||
|
Hello {
|
||||||
|
display_hdr: None,
|
||||||
|
..with_hdr.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(Hello::decode(&base.encode()).unwrap().display_hdr, None);
|
||||||
|
// A TRUNCATED trailing block (mid-datagram cut) degrades to None, never a partial read.
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&enc[..enc.len() - 1]).unwrap().display_hdr,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Exact wire length: 26 bitrate-era bytes + the 6 forced single-byte placeholders
|
||||||
|
// (name len, launch len, video_caps, audio_channels, video_codecs, preferred_codec) + the body.
|
||||||
|
assert_eq!(hdr_only.encode().len(), 26 + 6 + HDR_META_BODY_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_messages_disjoint_from_hello() {
|
||||||
|
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
|
||||||
|
// any abi_version — can be misparsed as a control message, and vice-versa.
|
||||||
|
for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] {
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: abi,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
display_hdr: None,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
|
assert!(Reconfigure::decode(&h).is_err());
|
||||||
|
}
|
||||||
|
// And a PairRequest never parses as a Hello.
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: "x".into(),
|
||||||
|
spake_a: vec![0u8; 33],
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(Hello::decode(&pr).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,3 +96,84 @@ pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io:
|
|||||||
.await
|
.await
|
||||||
.map_err(std::io::Error::other)
|
.map_err(std::io::Error::other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The control stream is read from a `select!` arm on both peers, so the read future is dropped
|
||||||
|
/// routinely — and quinn documents `read_exact` (what `io::read_msg` uses) as NOT cancel-safe.
|
||||||
|
/// [`io::MsgReader`] must survive that: the partial frame lives in the reader, not the future.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::io;
|
||||||
|
use crate::quic::test_util::connect_pair;
|
||||||
|
|
||||||
|
/// A frame whose halves land in different wakeups, with the read cancelled in between, must
|
||||||
|
/// still be delivered whole — and the NEXT frame must decode correctly too. Without a
|
||||||
|
/// resumable reader the consumed length prefix is lost, the following read takes two payload
|
||||||
|
/// bytes as a length, and every later control message is garbage for the rest of the session.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelled_mid_frame_read_resumes_without_desync() {
|
||||||
|
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||||
|
|
||||||
|
let first = b"the-frame-that-straddles-two-wakeups".to_vec();
|
||||||
|
let second = b"the-frame-after-it".to_vec();
|
||||||
|
let (f1, f2) = (first.clone(), second.clone());
|
||||||
|
|
||||||
|
let writer = tokio::spawn(async move {
|
||||||
|
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
|
||||||
|
let framed = crate::quic::frame(&f1);
|
||||||
|
// Length prefix + only part of the payload, then a real pause: this is the ClipOffer
|
||||||
|
// -sized frame split across two QUIC packets that made the bug reachable.
|
||||||
|
let split = 2 + f1.len() / 3;
|
||||||
|
send.write_all(&framed[..split]).await.expect("write head");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
|
||||||
|
send.write_all(&framed[split..]).await.expect("write tail");
|
||||||
|
send.write_all(&crate::quic::frame(&f2))
|
||||||
|
.await
|
||||||
|
.expect("write second");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
host_conn
|
||||||
|
});
|
||||||
|
|
||||||
|
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
|
||||||
|
let mut reader = io::MsgReader::new(recv);
|
||||||
|
|
||||||
|
// Cancel mid-frame — exactly what a sibling `select!` arm does.
|
||||||
|
let cancelled =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(30), reader.read_msg()).await;
|
||||||
|
assert!(
|
||||||
|
cancelled.is_err(),
|
||||||
|
"the head-only frame must not complete yet (test setup)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let got = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
|
||||||
|
.await
|
||||||
|
.expect("first frame must arrive after resuming")
|
||||||
|
.expect("first frame reads cleanly");
|
||||||
|
assert_eq!(got, first, "the cancelled read must resume, not lose bytes");
|
||||||
|
|
||||||
|
let got2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
|
||||||
|
.await
|
||||||
|
.expect("second frame must arrive")
|
||||||
|
.expect("second frame reads cleanly");
|
||||||
|
assert_eq!(got2, second, "stream must still be framed correctly");
|
||||||
|
|
||||||
|
let _host_conn = writer.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero-length frame is a legal encoding and must not stall the reader or eat the next one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn zero_length_frame_round_trips() {
|
||||||
|
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||||
|
let writer = tokio::spawn(async move {
|
||||||
|
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
|
||||||
|
send.write_all(&crate::quic::frame(&[])).await.unwrap();
|
||||||
|
send.write_all(&crate::quic::frame(b"after")).await.unwrap();
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
host_conn
|
||||||
|
});
|
||||||
|
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
|
||||||
|
let mut reader = io::MsgReader::new(recv);
|
||||||
|
assert!(reader.read_msg().await.unwrap().is_empty());
|
||||||
|
assert_eq!(reader.read_msg().await.unwrap(), b"after");
|
||||||
|
let _host_conn = writer.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,14 @@
|
|||||||
//! reported back for persisting). The data plane adds AES-GCM on top.
|
//! reported back for persisting). The data plane adds AES-GCM on top.
|
||||||
//! All integers little-endian; every message is `u16 length || payload`.
|
//! All integers little-endian; every message is `u16 length || payload`.
|
||||||
//!
|
//!
|
||||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): [`msgs`] the
|
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||||
//! handshake + typed control messages, [`pake`] the pairing SPAKE2, [`datagram`] the
|
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||||
//! 0xC9–0xCF plane codecs, [`io`] framed stream IO, [`clock`] skew estimation + mid-stream
|
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||||
//! re-sync, [`endpoint`] the quinn constructors. Every item is re-exported here, so all
|
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
||||||
//! existing `crate::quic::X` paths compile unchanged.
|
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||||
|
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||||
|
//! is re-exported here, so all existing `crate::quic::X` paths compile unchanged; each
|
||||||
|
//! module's tests sit at its own foot.
|
||||||
|
|
||||||
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
|
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
|
||||||
pub const MAGIC: &[u8; 4] = b"PKF1";
|
pub const MAGIC: &[u8; 4] = b"PKF1";
|
||||||
@@ -76,4 +79,4 @@ pub use pairing::*;
|
|||||||
pub use crate::reject::*;
|
pub use crate::reject::*;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
pub(crate) mod test_util;
|
||||||
|
|||||||
@@ -78,13 +78,16 @@ fn get_bytes(b: &[u8], off: usize) -> Result<(&[u8], usize)> {
|
|||||||
|
|
||||||
impl PairRequest {
|
impl PairRequest {
|
||||||
pub fn encode(&self) -> Vec<u8> {
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
let name = self.name.as_bytes();
|
// Same cap, same rule as Hello's copy of this field: truncate on a char boundary —
|
||||||
let n = name.len().min(64);
|
// a raw byte cut mid-sequence put invalid UTF-8 on the wire, and the host showed the
|
||||||
|
// name with a permanent replacement char in its paired-clients list.
|
||||||
|
let name = super::handshake::truncate_to(&self.name, HELLO_NAME_MAX).as_bytes();
|
||||||
|
let n = name.len();
|
||||||
let mut b = Vec::with_capacity(8 + n + self.spake_a.len());
|
let mut b = Vec::with_capacity(8 + n + self.spake_a.len());
|
||||||
b.extend_from_slice(CTL_MAGIC);
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
b.push(MSG_PAIR_REQUEST);
|
b.push(MSG_PAIR_REQUEST);
|
||||||
b.push(n as u8);
|
b.push(n as u8);
|
||||||
b.extend_from_slice(&name[..n]);
|
b.extend_from_slice(name);
|
||||||
put_bytes(&mut b, &self.spake_a);
|
put_bytes(&mut b, &self.spake_a);
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
@@ -171,3 +174,50 @@ impl PairResult {
|
|||||||
Ok(PairResult { ok: b[5] != 0 })
|
Ok(PairResult { ok: b[5] != 0 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_messages_roundtrip() {
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: "Enrico's Mac".into(),
|
||||||
|
spake_a: vec![1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr);
|
||||||
|
let pc = PairChallenge {
|
||||||
|
spake_b: vec![9; 33],
|
||||||
|
confirm: [7u8; 32],
|
||||||
|
};
|
||||||
|
assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc);
|
||||||
|
let pp = PairProof { confirm: [3u8; 32] };
|
||||||
|
assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp);
|
||||||
|
for ok in [true, false] {
|
||||||
|
assert_eq!(
|
||||||
|
PairResult::decode(&PairResult { ok }.encode()).unwrap().ok,
|
||||||
|
ok
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Length-exact: a truncated/padded PairProof is rejected.
|
||||||
|
let mut bad = pp.encode();
|
||||||
|
bad.push(0);
|
||||||
|
assert!(PairProof::decode(&bad).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_request_name_cap_respects_char_boundaries() {
|
||||||
|
// A multi-byte char straddling the 64-byte cap must be dropped whole (Hello's rule),
|
||||||
|
// not split mid-sequence into invalid UTF-8 the host then renders as U+FFFD forever.
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: format!("{}\u{00fc}", "x".repeat(HELLO_NAME_MAX - 1)),
|
||||||
|
spake_a: vec![1, 2, 3],
|
||||||
|
};
|
||||||
|
let dec = PairRequest::decode(&pr.encode()).unwrap();
|
||||||
|
assert!(dec.name.len() <= HELLO_NAME_MAX && dec.name.starts_with('x'));
|
||||||
|
assert!(
|
||||||
|
!dec.name.contains('\u{FFFD}'),
|
||||||
|
"name must never be split mid-char on the wire"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,36 @@ impl PairingPake {
|
|||||||
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
|
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
|
||||||
ct_eq(expected, got)
|
ct_eq(expected, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::quic::pake;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spake2_pairing_agrees_only_on_matching_pin_and_certs() {
|
||||||
|
let cfp = [0x11u8; 32];
|
||||||
|
let hfp = [0x22u8; 32];
|
||||||
|
|
||||||
|
// Right PIN, same fingerprint views on both sides → both confirmations agree.
|
||||||
|
let (ca, ma) = pake::start(true, "4321", &cfp, &hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client));
|
||||||
|
|
||||||
|
// Wrong PIN → different keys → confirmations DON'T match (one online guess wasted).
|
||||||
|
let (ca, ma) = pake::start(true, "0000", &cfp, &hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(!pake::verify(&a.client, &b.client));
|
||||||
|
|
||||||
|
// MITM: the two legs saw different host certs → no agreement even with the right PIN.
|
||||||
|
let attacker_hfp = [0x33u8; 32];
|
||||||
|
let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(!pake::verify(&a.client, &b.client));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Shared in-process QUIC loopback plumbing for the quic submodule tests.
|
||||||
|
use super::endpoint;
|
||||||
|
|
||||||
|
/// Stand up two loopback quinn endpoints, connect, and return
|
||||||
|
/// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller
|
||||||
|
/// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections.
|
||||||
|
pub(crate) async fn connect_pair() -> (
|
||||||
|
quinn::Endpoint,
|
||||||
|
quinn::Endpoint,
|
||||||
|
quinn::Connection,
|
||||||
|
quinn::Connection,
|
||||||
|
) {
|
||||||
|
let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||||
|
let addr = server.local_addr().unwrap();
|
||||||
|
let client = endpoint::client_insecure().unwrap();
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let incoming = server.accept().await.expect("incoming connection");
|
||||||
|
let conn = incoming.await.expect("host side connects");
|
||||||
|
(server, conn)
|
||||||
|
});
|
||||||
|
let client_conn = client
|
||||||
|
.connect(addr, "punktfunk")
|
||||||
|
.unwrap()
|
||||||
|
.await
|
||||||
|
.expect("client side connects");
|
||||||
|
let (server, host_conn) = accept.await.unwrap();
|
||||||
|
(server, client, host_conn, client_conn)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -102,160 +102,15 @@ fn stamp_received(mut f: Frame) -> Frame {
|
|||||||
f
|
f
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
|
mod perf;
|
||||||
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
|
mod replay;
|
||||||
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
|
mod seal;
|
||||||
/// at 60 fps — small frames and the probe's ~17-packet AUs stay strictly single-lane.
|
|
||||||
const TWO_LANE_MIN_PACKETS: usize = 256;
|
|
||||||
|
|
||||||
/// One two-lane seal hand-off: the frame's back-half wire buffers, sealed by the worker with
|
pub use perf::{PumpPerf, SealPerf};
|
||||||
/// nonces `seq_base + i` (the nonce order is deterministic per shard index, which is what
|
|
||||||
/// makes the split sound). Round-trips through the channels so the buffers return to the pool.
|
|
||||||
struct SealJob {
|
|
||||||
bufs: Vec<Vec<u8>>,
|
|
||||||
seq_base: u64,
|
|
||||||
timed: bool,
|
|
||||||
/// Worker-lane CPU ns (when `timed`) and the seal outcome, filled in by the worker.
|
|
||||||
ns: u64,
|
|
||||||
result: Result<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The persistent second seal lane: a worker thread that AES-GCM-seals the back half of a
|
use perf::TimedCoder;
|
||||||
/// large frame's packets while the send thread seals the front half. Rendezvous channels
|
use replay::{seq_of, ReplayWindow};
|
||||||
/// (bound 1) — the send thread submits, seals its half, then waits; no per-frame spawn.
|
use seal::{seal_wire_slice, SealJob, SealLane, TWO_LANE_MIN_PACKETS};
|
||||||
/// Dropping the struct closes the channel and the worker exits.
|
|
||||||
struct SealLane {
|
|
||||||
to_worker: std::sync::mpsc::SyncSender<SealJob>,
|
|
||||||
from_worker: std::sync::mpsc::Receiver<SealJob>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SealLane {
|
|
||||||
fn spawn(crypto: std::sync::Arc<SessionCrypto>) -> Option<SealLane> {
|
|
||||||
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
|
||||||
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("punktfunk-seal2".into())
|
|
||||||
.spawn(move || {
|
|
||||||
while let Ok(mut job) = jobs.recv() {
|
|
||||||
let t0 = job.timed.then(std::time::Instant::now);
|
|
||||||
job.result = seal_wire_slice(&crypto, &mut job.bufs, job.seq_base);
|
|
||||||
if let Some(t0) = t0 {
|
|
||||||
job.ns = t0.elapsed().as_nanos() as u64;
|
|
||||||
}
|
|
||||||
if done_tx.send(job).is_err() {
|
|
||||||
break; // session gone mid-frame — nothing left to seal for
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok()?;
|
|
||||||
Some(SealLane {
|
|
||||||
to_worker,
|
|
||||||
from_worker,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seal a run of pre-written wire buffers in place: buffer `i` is `seq(8) ‖ plaintext ‖ tag
|
|
||||||
/// scratch` and seals over `[8..]` with sequence `seq_base + i` — the exact per-packet layout
|
|
||||||
/// and nonce order of the fused single-lane path. Shared by both lanes.
|
|
||||||
fn seal_wire_slice(c: &SessionCrypto, wires: &mut [Vec<u8>], seq_base: u64) -> Result<()> {
|
|
||||||
for (i, wire) in wires.iter_mut().enumerate() {
|
|
||||||
c.seal_in_place(seq_base.wrapping_add(i as u64), &mut wire[8..])?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accumulated client receive-path stage timings since the last [`Session::take_pump_perf`].
|
|
||||||
/// Answers "where does the pump core go" at line rate: kernel drain (`recv_ns`) vs AES-GCM
|
|
||||||
/// (`decrypt_ns`) vs reassembly+FEC (`reasm_ns`, the `Reassembler::push` round-trip including
|
|
||||||
/// shard copies and block reconstruction). 2026-07-14 sweep context: the pump pegs one core at
|
|
||||||
/// ~1.5 Gbps wire, ~85% of it userspace — this split is what Phase 2.1 (pooled reassembly) is
|
|
||||||
/// validated against.
|
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
|
||||||
pub struct PumpPerf {
|
|
||||||
/// ns inside `recv_batch` (recvmmsg / recvmsg_x), i.e. syscall + kernel copy.
|
|
||||||
pub recv_ns: u64,
|
|
||||||
/// ns inside `open_in_place` across all datagrams (AES-128-GCM + replay-window upkeep).
|
|
||||||
pub decrypt_ns: u64,
|
|
||||||
/// ns inside `Reassembler::push` (header parse, shard copy, FEC reconstruct, AU assembly).
|
|
||||||
pub reasm_ns: u64,
|
|
||||||
/// recv_batch calls (batches) and datagrams processed over the accumulation window.
|
|
||||||
pub batches: u64,
|
|
||||||
pub packets: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accumulated host send-path stage timings since the last [`Session::take_seal_perf`] (plan
|
|
||||||
/// Phase 0.4, host half). Answers "where does the send thread go" at rate: FEC parity
|
|
||||||
/// generation (`fec_ns`, inside [`ErasureCoder::encode_into`]) vs AES-GCM (`seal_ns`,
|
|
||||||
/// per-packet `seal_in_place`) vs the socket handoff (`sock_ns` — `send_gso`/`sendmmsg`
|
|
||||||
/// syscalls; the internal submit paths time it here, the paced video path folds its chunk
|
|
||||||
/// sends in via [`Session::note_sock_ns`]). The Phase 1.5 gate reads off this split: build
|
|
||||||
/// two-lane seal only if `seal_ns` exceeds ~15% of the send thread at 2 Gbps.
|
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
|
||||||
pub struct SealPerf {
|
|
||||||
/// ns inside `ErasureCoder::encode_into` (parity generation).
|
|
||||||
pub fec_ns: u64,
|
|
||||||
/// ns inside `seal_in_place` across all wire packets (AES-128-GCM).
|
|
||||||
pub seal_ns: u64,
|
|
||||||
/// ns inside `send_sealed` (socket syscalls), where the session can see it.
|
|
||||||
pub sock_ns: u64,
|
|
||||||
/// Frames sealed and wire packets sealed over the accumulation window.
|
|
||||||
pub frames: u64,
|
|
||||||
pub packets: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`ErasureCoder`] shim accumulating the time spent in `encode_into` (the send-path FEC
|
|
||||||
/// stage) — only constructed when `PUNKTFUNK_PERF` armed the session's [`SealPerf`]. The
|
|
||||||
/// counter is atomic purely to satisfy the trait's `Sync` bound; it lives on one thread.
|
|
||||||
struct TimedCoder<'a> {
|
|
||||||
inner: &'a dyn ErasureCoder,
|
|
||||||
ns: &'a std::sync::atomic::AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ErasureCoder for TimedCoder<'_> {
|
|
||||||
fn scheme(&self) -> crate::config::FecScheme {
|
|
||||||
self.inner.scheme()
|
|
||||||
}
|
|
||||||
fn encode(
|
|
||||||
&self,
|
|
||||||
data: &[&[u8]],
|
|
||||||
recovery_count: usize,
|
|
||||||
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
|
|
||||||
self.inner.encode(data, recovery_count)
|
|
||||||
}
|
|
||||||
fn encode_into(
|
|
||||||
&self,
|
|
||||||
data: &[&[u8]],
|
|
||||||
recovery_count: usize,
|
|
||||||
out: &mut Vec<Vec<u8>>,
|
|
||||||
) -> std::result::Result<(), crate::fec::FecError> {
|
|
||||||
let t0 = std::time::Instant::now();
|
|
||||||
let r = self.inner.encode_into(data, recovery_count, out);
|
|
||||||
self.ns.fetch_add(
|
|
||||||
t0.elapsed().as_nanos() as u64,
|
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
r
|
|
||||||
}
|
|
||||||
fn reconstruct(
|
|
||||||
&self,
|
|
||||||
data_count: usize,
|
|
||||||
recovery_count: usize,
|
|
||||||
received: &mut [Option<Vec<u8>>],
|
|
||||||
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
|
|
||||||
self.inner.reconstruct(data_count, recovery_count, received)
|
|
||||||
}
|
|
||||||
fn reconstruct_into(
|
|
||||||
&self,
|
|
||||||
recovery_count: usize,
|
|
||||||
data: &mut [&mut [u8]],
|
|
||||||
have: &[bool],
|
|
||||||
recovery: &[(usize, &[u8])],
|
|
||||||
) -> std::result::Result<(), crate::fec::FecError> {
|
|
||||||
self.inner
|
|
||||||
.reconstruct_into(recovery_count, data, have, recovery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). 128 keeps
|
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). 128 keeps
|
||||||
/// the syscall rate ≤ ~3.4k/s even at the ~430k pkt/s the post-2026-07-14 receive path delivers
|
/// the syscall rate ≤ ~3.4k/s even at the ~430k pkt/s the post-2026-07-14 receive path delivers
|
||||||
@@ -503,7 +358,10 @@ impl Session {
|
|||||||
}
|
}
|
||||||
let mut split_done = false;
|
let mut split_done = false;
|
||||||
if two_lane && used >= TWO_LANE_MIN_PACKETS {
|
if two_lane && used >= TWO_LANE_MIN_PACKETS {
|
||||||
if let Some(lane) = seal_lane.as_ref() {
|
// Take the lane for the frame: a healthy round-trip puts it back; either
|
||||||
|
// failure arm drops the corpse so the next large frame respawns a fresh one
|
||||||
|
// instead of retrying a dead channel forever.
|
||||||
|
if let Some(lane) = seal_lane.take() {
|
||||||
let half = used / 2;
|
let half = used / 2;
|
||||||
let mut tail = std::mem::take(lane_scratch);
|
let mut tail = std::mem::take(lane_scratch);
|
||||||
tail.extend(wires.drain(half..));
|
tail.extend(wires.drain(half..));
|
||||||
@@ -514,26 +372,42 @@ impl Session {
|
|||||||
ns: 0,
|
ns: 0,
|
||||||
result: Ok(()),
|
result: Ok(()),
|
||||||
};
|
};
|
||||||
if lane.to_worker.send(job).is_ok() {
|
match lane.to_worker.send(job) {
|
||||||
// Seal the front half while the worker runs; collect BOTH results
|
Ok(()) => {
|
||||||
// before erroring so the lane is always drained and reusable.
|
// Seal the front half while the worker runs; collect BOTH results
|
||||||
let t0 = perf_armed.then(std::time::Instant::now);
|
// before erroring so the lane is always drained and reusable.
|
||||||
let front = seal_wire_slice(c, &mut wires, seq_base);
|
let t0 = perf_armed.then(std::time::Instant::now);
|
||||||
if let Some(t0) = t0 {
|
let front = seal_wire_slice(c, &mut wires, seq_base);
|
||||||
seal_ns += t0.elapsed().as_nanos() as u64;
|
if let Some(t0) = t0 {
|
||||||
|
seal_ns += t0.elapsed().as_nanos() as u64;
|
||||||
|
}
|
||||||
|
match lane.from_worker.recv() {
|
||||||
|
Ok(mut done) => {
|
||||||
|
*seal_lane = Some(lane);
|
||||||
|
seal_ns += done.ns;
|
||||||
|
wires.append(&mut done.bufs);
|
||||||
|
*lane_scratch = done.bufs;
|
||||||
|
front?;
|
||||||
|
done.result?;
|
||||||
|
split_done = true;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// The worker died holding the back half — the frame is
|
||||||
|
// unrecoverable (its packets are gone), but the error now
|
||||||
|
// SURFACES instead of `Ok` with half an access unit.
|
||||||
|
front?;
|
||||||
|
return Err(PunktfunkError::Unsupported("seal lane died"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::SendError(job)) => {
|
||||||
|
// The worker is gone but the channel hands the job back: reclaim
|
||||||
|
// the back half so the single-lane pass below seals the WHOLE
|
||||||
|
// frame — previously this fall-through sealed and returned only
|
||||||
|
// the front half, silently, as `Ok`.
|
||||||
|
wires.extend(job.bufs);
|
||||||
}
|
}
|
||||||
let mut done = lane
|
|
||||||
.from_worker
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| PunktfunkError::Unsupported("seal lane died"))?;
|
|
||||||
seal_ns += done.ns;
|
|
||||||
wires.append(&mut done.bufs);
|
|
||||||
*lane_scratch = done.bufs;
|
|
||||||
front?;
|
|
||||||
done.result?;
|
|
||||||
split_done = true;
|
|
||||||
}
|
}
|
||||||
// A failed send means the worker is gone — fall through to single-lane.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !split_done {
|
if !split_done {
|
||||||
@@ -836,102 +710,6 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the AEAD-authenticated 8-byte big-endian sequence prefix from a sealed wire datagram.
|
|
||||||
/// Only called on the encrypted receive path, where a preceding successful open has already
|
|
||||||
/// established `wire.len() >= 8`.
|
|
||||||
fn seq_of(wire: &[u8]) -> u64 {
|
|
||||||
u64::from_be_bytes(wire[..8].try_into().unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
|
|
||||||
/// datagram, so this must cover the reassembler's 120 ms loss window
|
|
||||||
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
|
|
||||||
/// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
|
|
||||||
/// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
|
|
||||||
/// ~125k pkt/s of a 1 Gbps stream; 32768 topped out around ~2 Gbps — which the client now
|
|
||||||
/// exceeds: the 2026-07-14 zero-copy + hardware-AES work measured ~4.8 Gbps wire ≈ 430k pkt/s
|
|
||||||
/// delivered). 131072 covers 120 ms up to ~1.09M pkt/s (≈12 Gbps wire) and is effectively
|
|
||||||
/// unbounded for the sparse input stream, while still bounding how far back a replay could
|
|
||||||
/// hide; the bitmap costs 16 KiB per session.
|
|
||||||
const REPLAY_WINDOW: u64 = 131072;
|
|
||||||
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
|
||||||
|
|
||||||
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
|
||||||
/// its datagrams from 0, and the protocol never legitimately re-sends a sequence (FEC recovery
|
|
||||||
/// shards get fresh ones), so a sequence seen twice is a replay. The AEAD tag already authenticates
|
|
||||||
/// the sequence — a forged one can't open — so this only has to reject *duplicates* of validly
|
|
||||||
/// sealed datagrams (and anything older than the window, which we can no longer prove is fresh).
|
|
||||||
/// Genuine reordering within the window is accepted. Bitmap-per-sequence, indexed `seq % WINDOW`.
|
|
||||||
struct ReplayWindow {
|
|
||||||
/// Highest sequence accepted so far; `seen` stays false until the first datagram.
|
|
||||||
highest: u64,
|
|
||||||
seen: bool,
|
|
||||||
/// One bit per in-window sequence in `(highest - WINDOW, highest]`.
|
|
||||||
bits: [u64; REPLAY_WORDS],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReplayWindow {
|
|
||||||
fn new() -> ReplayWindow {
|
|
||||||
ReplayWindow {
|
|
||||||
highest: 0,
|
|
||||||
seen: false,
|
|
||||||
bits: [0; REPLAY_WORDS],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn word_bit(seq: u64) -> (usize, u64) {
|
|
||||||
let idx = (seq % REPLAY_WINDOW) as usize;
|
|
||||||
(idx / 64, 1u64 << (idx % 64))
|
|
||||||
}
|
|
||||||
fn is_set(&self, seq: u64) -> bool {
|
|
||||||
let (w, b) = Self::word_bit(seq);
|
|
||||||
self.bits[w] & b != 0
|
|
||||||
}
|
|
||||||
fn set(&mut self, seq: u64) {
|
|
||||||
let (w, b) = Self::word_bit(seq);
|
|
||||||
self.bits[w] |= b;
|
|
||||||
}
|
|
||||||
fn unset(&mut self, seq: u64) {
|
|
||||||
let (w, b) = Self::word_bit(seq);
|
|
||||||
self.bits[w] &= !b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record `seq`, returning `true` if it's fresh (accept) or `false` if it's a replay / too old.
|
|
||||||
fn accept(&mut self, seq: u64) -> bool {
|
|
||||||
if !self.seen {
|
|
||||||
self.seen = true;
|
|
||||||
self.highest = seq;
|
|
||||||
self.set(seq);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if seq > self.highest {
|
|
||||||
// Advance the window. Sequences between the old and new high slide in unseen, so clear
|
|
||||||
// their (possibly stale, from a full window ago) slots — unless we jumped an entire
|
|
||||||
// window, in which case wipe the bitmap wholesale.
|
|
||||||
if seq - self.highest >= REPLAY_WINDOW {
|
|
||||||
self.bits = [0; REPLAY_WORDS];
|
|
||||||
} else {
|
|
||||||
let mut s = self.highest + 1;
|
|
||||||
while s < seq {
|
|
||||||
self.unset(s);
|
|
||||||
s += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.highest = seq;
|
|
||||||
self.set(seq);
|
|
||||||
true
|
|
||||||
} else if self.highest - seq >= REPLAY_WINDOW || self.is_set(seq) {
|
|
||||||
// Older than the window (can't prove it isn't a replay) or already seen (a duplicate) —
|
|
||||||
// either way, drop it.
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
self.set(seq); // in-window and not yet seen — a genuine reorder
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod wire_equivalence_tests {
|
mod wire_equivalence_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1031,6 +809,42 @@ mod wire_equivalence_tests {
|
|||||||
(0..len).map(|i| (i * 31 + 7) as u8).collect()
|
(0..len).map(|i| (i * 31 + 7) as u8).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A dead seal lane (worker gone, channels dangling) must degrade to a single-lane seal of
|
||||||
|
/// the WHOLE frame — the old fall-through sealed and returned only the front half as `Ok` —
|
||||||
|
/// and the corpse must be dropped so the next large frame respawns a fresh lane.
|
||||||
|
#[test]
|
||||||
|
fn dead_seal_lane_falls_back_to_single_lane_whole_frame() {
|
||||||
|
let mut opt = host_session(host_cfg(FecScheme::Gf16, 20, true));
|
||||||
|
let mut refr = host_session(host_cfg(FecScheme::Gf16, 20, true));
|
||||||
|
// A lane whose worker has already exited: both far ends dropped, so `send` fails
|
||||||
|
// immediately and hands the job (with the frame's back half) back.
|
||||||
|
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
||||||
|
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
||||||
|
drop(jobs);
|
||||||
|
drop(done_tx);
|
||||||
|
opt.seal_lane = Some(SealLane {
|
||||||
|
to_worker,
|
||||||
|
from_worker,
|
||||||
|
});
|
||||||
|
let frame = pattern(20000); // > TWO_LANE_MIN_PACKETS wire packets → takes the split path
|
||||||
|
let got = opt.seal_frame(&frame, 7, 0).unwrap();
|
||||||
|
let want = seal_via_wrapper(&mut refr, &frame, 7, 0);
|
||||||
|
assert_eq!(got, want, "fallback must seal the whole frame, not half");
|
||||||
|
assert!(
|
||||||
|
opt.seal_lane.is_none(),
|
||||||
|
"the dead lane must be dropped, not retried forever"
|
||||||
|
);
|
||||||
|
// The next large frame respawns a fresh, working lane.
|
||||||
|
opt.reclaim_wires(got);
|
||||||
|
let got2 = opt.seal_frame(&frame, 8, 1).unwrap();
|
||||||
|
let want2 = seal_via_wrapper(&mut refr, &frame, 8, 1);
|
||||||
|
assert_eq!(got2, want2);
|
||||||
|
assert!(
|
||||||
|
opt.seal_lane.is_some(),
|
||||||
|
"a fresh lane respawns on the next large frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Partial delivery (plan §4.4): a chunk-aligned frame that loses shards past FEC's
|
/// Partial delivery (plan §4.4): a chunk-aligned frame that loses shards past FEC's
|
||||||
/// reach is DELIVERED once it ages out — `complete: false`, received shards at their
|
/// reach is DELIVERED once it ages out — `complete: false`, received shards at their
|
||||||
/// exact offsets, missing ranges zero-filled — instead of silently dropping. Plain
|
/// exact offsets, missing ranges zero-filled — instead of silently dropping. Plain
|
||||||
@@ -1126,78 +940,3 @@ mod wire_equivalence_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod replay_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_in_order_and_rejects_duplicates() {
|
|
||||||
let mut w = ReplayWindow::new();
|
|
||||||
for seq in 0..1000 {
|
|
||||||
assert!(w.accept(seq), "fresh in-order seq {seq} must be accepted");
|
|
||||||
}
|
|
||||||
// Every one of those is now a replay.
|
|
||||||
for seq in 0..1000 {
|
|
||||||
assert!(!w.accept(seq), "replayed seq {seq} must be rejected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_reorder_within_window_once() {
|
|
||||||
let mut w = ReplayWindow::new();
|
|
||||||
assert!(w.accept(100));
|
|
||||||
// Earlier-but-in-window sequences (a genuine reorder) are accepted exactly once.
|
|
||||||
assert!(w.accept(80));
|
|
||||||
assert!(!w.accept(80), "second copy of a reordered seq is a replay");
|
|
||||||
assert!(w.accept(99));
|
|
||||||
assert!(
|
|
||||||
!w.accept(100),
|
|
||||||
"the high-water seq itself can't be replayed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_older_than_window() {
|
|
||||||
let mut w = ReplayWindow::new();
|
|
||||||
assert!(w.accept(REPLAY_WINDOW * 2));
|
|
||||||
// Anything a full window or more behind the high-water mark is dropped (can't prove fresh).
|
|
||||||
assert!(!w.accept(REPLAY_WINDOW * 2 - REPLAY_WINDOW));
|
|
||||||
assert!(!w.accept(0));
|
|
||||||
// But just inside the window is still accepted.
|
|
||||||
assert!(w.accept(REPLAY_WINDOW * 2 - (REPLAY_WINDOW - 1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn large_forward_jump_wipes_stale_bits() {
|
|
||||||
let mut w = ReplayWindow::new();
|
|
||||||
assert!(w.accept(5));
|
|
||||||
// Jump far forward (more than a window). The slot for an old seq that aliases 5 mod WINDOW
|
|
||||||
// must read as unseen afterward, i.e. the jump cleared it — so a NEW seq there is accepted.
|
|
||||||
let far = 10 * REPLAY_WINDOW + 5;
|
|
||||||
assert!(w.accept(far));
|
|
||||||
assert!(
|
|
||||||
!w.accept(5),
|
|
||||||
"the pre-jump seq is now far older than the window"
|
|
||||||
);
|
|
||||||
// A fresh seq aliasing 5 (mod WINDOW) but inside the new window is accepted, proving the
|
|
||||||
// stale bit was cleared rather than mistaken for a replay.
|
|
||||||
assert!(w.accept(far - REPLAY_WINDOW + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn first_seq_need_not_be_zero() {
|
|
||||||
// Startup loss can mean the first datagram we ever open isn't seq 0.
|
|
||||||
let mut w = ReplayWindow::new();
|
|
||||||
assert!(w.accept(42));
|
|
||||||
assert!(!w.accept(42));
|
|
||||||
assert!(w.accept(43));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn seq_of_reads_the_big_endian_prefix() {
|
|
||||||
let mut wire = 0x0102_0304_0506_0708u64.to_be_bytes().to_vec();
|
|
||||||
wire.extend_from_slice(b"ciphertext-and-tag");
|
|
||||||
assert_eq!(seq_of(&wire), 0x0102_0304_0506_0708);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! `PUNKTFUNK_PERF` stage-timing telemetry for the two hot paths: where the client pump
|
||||||
|
//! and the host send thread actually spend their time, accumulated per report window and
|
||||||
|
//! drained via [`Session::take_pump_perf`](super::Session::take_pump_perf) /
|
||||||
|
//! [`Session::take_seal_perf`](super::Session::take_seal_perf).
|
||||||
|
|
||||||
|
use crate::fec::ErasureCoder;
|
||||||
|
|
||||||
|
/// Accumulated client receive-path stage timings since the last [`Session::take_pump_perf`](super::Session::take_pump_perf).
|
||||||
|
/// Answers "where does the pump core go" at line rate: kernel drain (`recv_ns`) vs AES-GCM
|
||||||
|
/// (`decrypt_ns`) vs reassembly+FEC (`reasm_ns`, the `Reassembler::push` round-trip including
|
||||||
|
/// shard copies and block reconstruction). 2026-07-14 sweep context: the pump pegs one core at
|
||||||
|
/// ~1.5 Gbps wire, ~85% of it userspace — this split is what Phase 2.1 (pooled reassembly) is
|
||||||
|
/// validated against.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct PumpPerf {
|
||||||
|
/// ns inside `recv_batch` (recvmmsg / recvmsg_x), i.e. syscall + kernel copy.
|
||||||
|
pub recv_ns: u64,
|
||||||
|
/// ns inside `open_in_place` across all datagrams (AES-128-GCM + replay-window upkeep).
|
||||||
|
pub decrypt_ns: u64,
|
||||||
|
/// ns inside `Reassembler::push` (header parse, shard copy, FEC reconstruct, AU assembly).
|
||||||
|
pub reasm_ns: u64,
|
||||||
|
/// recv_batch calls (batches) and datagrams processed over the accumulation window.
|
||||||
|
pub batches: u64,
|
||||||
|
pub packets: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulated host send-path stage timings since the last [`Session::take_seal_perf`](super::Session::take_seal_perf) (plan
|
||||||
|
/// Phase 0.4, host half). Answers "where does the send thread go" at rate: FEC parity
|
||||||
|
/// generation (`fec_ns`, inside [`ErasureCoder::encode_into`]) vs AES-GCM (`seal_ns`,
|
||||||
|
/// per-packet `seal_in_place`) vs the socket handoff (`sock_ns` — `send_gso`/`sendmmsg`
|
||||||
|
/// syscalls; the internal submit paths time it here, the paced video path folds its chunk
|
||||||
|
/// sends in via [`Session::note_sock_ns`](super::Session::note_sock_ns)). The Phase 1.5 gate reads off this split: build
|
||||||
|
/// two-lane seal only if `seal_ns` exceeds ~15% of the send thread at 2 Gbps.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct SealPerf {
|
||||||
|
/// ns inside `ErasureCoder::encode_into` (parity generation).
|
||||||
|
pub fec_ns: u64,
|
||||||
|
/// ns inside `seal_in_place` across all wire packets (AES-128-GCM).
|
||||||
|
pub seal_ns: u64,
|
||||||
|
/// ns inside `send_sealed` (socket syscalls), where the session can see it.
|
||||||
|
pub sock_ns: u64,
|
||||||
|
/// Frames sealed and wire packets sealed over the accumulation window.
|
||||||
|
pub frames: u64,
|
||||||
|
pub packets: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`ErasureCoder`] shim accumulating the time spent in `encode_into` (the send-path FEC
|
||||||
|
/// stage) — only constructed when `PUNKTFUNK_PERF` armed the session's [`SealPerf`]. The
|
||||||
|
/// counter is atomic purely to satisfy the trait's `Sync` bound; it lives on one thread.
|
||||||
|
pub(super) struct TimedCoder<'a> {
|
||||||
|
pub(super) inner: &'a dyn ErasureCoder,
|
||||||
|
pub(super) ns: &'a std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErasureCoder for TimedCoder<'_> {
|
||||||
|
fn scheme(&self) -> crate::config::FecScheme {
|
||||||
|
self.inner.scheme()
|
||||||
|
}
|
||||||
|
fn encode(
|
||||||
|
&self,
|
||||||
|
data: &[&[u8]],
|
||||||
|
recovery_count: usize,
|
||||||
|
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
|
||||||
|
self.inner.encode(data, recovery_count)
|
||||||
|
}
|
||||||
|
fn encode_into(
|
||||||
|
&self,
|
||||||
|
data: &[&[u8]],
|
||||||
|
recovery_count: usize,
|
||||||
|
out: &mut Vec<Vec<u8>>,
|
||||||
|
) -> std::result::Result<(), crate::fec::FecError> {
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let r = self.inner.encode_into(data, recovery_count, out);
|
||||||
|
self.ns.fetch_add(
|
||||||
|
t0.elapsed().as_nanos() as u64,
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
fn reconstruct(
|
||||||
|
&self,
|
||||||
|
data_count: usize,
|
||||||
|
recovery_count: usize,
|
||||||
|
received: &mut [Option<Vec<u8>>],
|
||||||
|
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
|
||||||
|
self.inner.reconstruct(data_count, recovery_count, received)
|
||||||
|
}
|
||||||
|
fn reconstruct_into(
|
||||||
|
&self,
|
||||||
|
recovery_count: usize,
|
||||||
|
data: &mut [&mut [u8]],
|
||||||
|
have: &[bool],
|
||||||
|
recovery: &[(usize, &[u8])],
|
||||||
|
) -> std::result::Result<(), crate::fec::FecError> {
|
||||||
|
self.inner
|
||||||
|
.reconstruct_into(recovery_count, data, have, recovery)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! Sliding-window anti-replay filter over the AEAD-authenticated wire sequence
|
||||||
|
//! (plan §1). Applied on both encrypted receive paths —
|
||||||
|
//! [`Session::poll_frame`](super::Session::poll_frame) and
|
||||||
|
//! [`Session::poll_input`](super::Session::poll_input).
|
||||||
|
|
||||||
|
/// Extract the AEAD-authenticated 8-byte big-endian sequence prefix from a sealed wire datagram.
|
||||||
|
/// Only called on the encrypted receive path, where a preceding successful open has already
|
||||||
|
/// established `wire.len() >= 8`.
|
||||||
|
pub(super) fn seq_of(wire: &[u8]) -> u64 {
|
||||||
|
u64::from_be_bytes(wire[..8].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
|
||||||
|
/// datagram, so this must cover the reassembler's 120 ms loss window
|
||||||
|
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
|
||||||
|
/// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
|
||||||
|
/// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
|
||||||
|
/// ~125k pkt/s of a 1 Gbps stream; 32768 topped out around ~2 Gbps — which the client now
|
||||||
|
/// exceeds: the 2026-07-14 zero-copy + hardware-AES work measured ~4.8 Gbps wire ≈ 430k pkt/s
|
||||||
|
/// delivered). 131072 covers 120 ms up to ~1.09M pkt/s (≈12 Gbps wire) and is effectively
|
||||||
|
/// unbounded for the sparse input stream, while still bounding how far back a replay could
|
||||||
|
/// hide; the bitmap costs 16 KiB per session.
|
||||||
|
const REPLAY_WINDOW: u64 = 131072;
|
||||||
|
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
||||||
|
|
||||||
|
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
||||||
|
/// its datagrams from 0, and the protocol never legitimately re-sends a sequence (FEC recovery
|
||||||
|
/// shards get fresh ones), so a sequence seen twice is a replay. The AEAD tag already authenticates
|
||||||
|
/// the sequence — a forged one can't open — so this only has to reject *duplicates* of validly
|
||||||
|
/// sealed datagrams (and anything older than the window, which we can no longer prove is fresh).
|
||||||
|
/// Genuine reordering within the window is accepted. Bitmap-per-sequence, indexed `seq % WINDOW`.
|
||||||
|
pub(super) struct ReplayWindow {
|
||||||
|
/// Highest sequence accepted so far; `seen` stays false until the first datagram.
|
||||||
|
highest: u64,
|
||||||
|
seen: bool,
|
||||||
|
/// One bit per in-window sequence in `(highest - WINDOW, highest]`.
|
||||||
|
bits: [u64; REPLAY_WORDS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplayWindow {
|
||||||
|
pub(super) fn new() -> ReplayWindow {
|
||||||
|
ReplayWindow {
|
||||||
|
highest: 0,
|
||||||
|
seen: false,
|
||||||
|
bits: [0; REPLAY_WORDS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn word_bit(seq: u64) -> (usize, u64) {
|
||||||
|
let idx = (seq % REPLAY_WINDOW) as usize;
|
||||||
|
(idx / 64, 1u64 << (idx % 64))
|
||||||
|
}
|
||||||
|
fn is_set(&self, seq: u64) -> bool {
|
||||||
|
let (w, b) = Self::word_bit(seq);
|
||||||
|
self.bits[w] & b != 0
|
||||||
|
}
|
||||||
|
fn set(&mut self, seq: u64) {
|
||||||
|
let (w, b) = Self::word_bit(seq);
|
||||||
|
self.bits[w] |= b;
|
||||||
|
}
|
||||||
|
fn unset(&mut self, seq: u64) {
|
||||||
|
let (w, b) = Self::word_bit(seq);
|
||||||
|
self.bits[w] &= !b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record `seq`, returning `true` if it's fresh (accept) or `false` if it's a replay / too old.
|
||||||
|
pub(super) fn accept(&mut self, seq: u64) -> bool {
|
||||||
|
if !self.seen {
|
||||||
|
self.seen = true;
|
||||||
|
self.highest = seq;
|
||||||
|
self.set(seq);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if seq > self.highest {
|
||||||
|
// Advance the window. Sequences between the old and new high slide in unseen, so clear
|
||||||
|
// their (possibly stale, from a full window ago) slots — unless we jumped an entire
|
||||||
|
// window, in which case wipe the bitmap wholesale.
|
||||||
|
if seq - self.highest >= REPLAY_WINDOW {
|
||||||
|
self.bits = [0; REPLAY_WORDS];
|
||||||
|
} else {
|
||||||
|
let mut s = self.highest + 1;
|
||||||
|
while s < seq {
|
||||||
|
self.unset(s);
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.highest = seq;
|
||||||
|
self.set(seq);
|
||||||
|
true
|
||||||
|
} else if self.highest - seq >= REPLAY_WINDOW || self.is_set(seq) {
|
||||||
|
// Older than the window (can't prove it isn't a replay) or already seen (a duplicate) —
|
||||||
|
// either way, drop it.
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.set(seq); // in-window and not yet seen — a genuine reorder
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_in_order_and_rejects_duplicates() {
|
||||||
|
let mut w = ReplayWindow::new();
|
||||||
|
for seq in 0..1000 {
|
||||||
|
assert!(w.accept(seq), "fresh in-order seq {seq} must be accepted");
|
||||||
|
}
|
||||||
|
// Every one of those is now a replay.
|
||||||
|
for seq in 0..1000 {
|
||||||
|
assert!(!w.accept(seq), "replayed seq {seq} must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_reorder_within_window_once() {
|
||||||
|
let mut w = ReplayWindow::new();
|
||||||
|
assert!(w.accept(100));
|
||||||
|
// Earlier-but-in-window sequences (a genuine reorder) are accepted exactly once.
|
||||||
|
assert!(w.accept(80));
|
||||||
|
assert!(!w.accept(80), "second copy of a reordered seq is a replay");
|
||||||
|
assert!(w.accept(99));
|
||||||
|
assert!(
|
||||||
|
!w.accept(100),
|
||||||
|
"the high-water seq itself can't be replayed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_older_than_window() {
|
||||||
|
let mut w = ReplayWindow::new();
|
||||||
|
assert!(w.accept(REPLAY_WINDOW * 2));
|
||||||
|
// Anything a full window or more behind the high-water mark is dropped (can't prove fresh).
|
||||||
|
assert!(!w.accept(REPLAY_WINDOW * 2 - REPLAY_WINDOW));
|
||||||
|
assert!(!w.accept(0));
|
||||||
|
// But just inside the window is still accepted.
|
||||||
|
assert!(w.accept(REPLAY_WINDOW * 2 - (REPLAY_WINDOW - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_forward_jump_wipes_stale_bits() {
|
||||||
|
let mut w = ReplayWindow::new();
|
||||||
|
assert!(w.accept(5));
|
||||||
|
// Jump far forward (more than a window). The slot for an old seq that aliases 5 mod WINDOW
|
||||||
|
// must read as unseen afterward, i.e. the jump cleared it — so a NEW seq there is accepted.
|
||||||
|
let far = 10 * REPLAY_WINDOW + 5;
|
||||||
|
assert!(w.accept(far));
|
||||||
|
assert!(
|
||||||
|
!w.accept(5),
|
||||||
|
"the pre-jump seq is now far older than the window"
|
||||||
|
);
|
||||||
|
// A fresh seq aliasing 5 (mod WINDOW) but inside the new window is accepted, proving the
|
||||||
|
// stale bit was cleared rather than mistaken for a replay.
|
||||||
|
assert!(w.accept(far - REPLAY_WINDOW + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_seq_need_not_be_zero() {
|
||||||
|
// Startup loss can mean the first datagram we ever open isn't seq 0.
|
||||||
|
let mut w = ReplayWindow::new();
|
||||||
|
assert!(w.accept(42));
|
||||||
|
assert!(!w.accept(42));
|
||||||
|
assert!(w.accept(43));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seq_of_reads_the_big_endian_prefix() {
|
||||||
|
let mut wire = 0x0102_0304_0506_0708u64.to_be_bytes().to_vec();
|
||||||
|
wire.extend_from_slice(b"ciphertext-and-tag");
|
||||||
|
assert_eq!(seq_of(&wire), 0x0102_0304_0506_0708);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! The second seal lane (plan Phase 1.5): a persistent worker thread that AES-GCM-seals
|
||||||
|
//! the back half of a large frame's wire packets while the send thread seals the front.
|
||||||
|
//! [`Session`](super::Session)`::seal_frame_inner` owns the split policy; this module owns
|
||||||
|
//! the lane machinery and the shared per-slice seal loop.
|
||||||
|
|
||||||
|
use crate::crypto::SessionCrypto;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
|
||||||
|
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
|
||||||
|
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
|
||||||
|
/// at 60 fps — small frames and the probe's ~17-packet AUs stay strictly single-lane.
|
||||||
|
pub(super) const TWO_LANE_MIN_PACKETS: usize = 256;
|
||||||
|
|
||||||
|
/// One two-lane seal hand-off: the frame's back-half wire buffers, sealed by the worker with
|
||||||
|
/// nonces `seq_base + i` (the nonce order is deterministic per shard index, which is what
|
||||||
|
/// makes the split sound). Round-trips through the channels so the buffers return to the pool.
|
||||||
|
pub(super) struct SealJob {
|
||||||
|
pub(super) bufs: Vec<Vec<u8>>,
|
||||||
|
pub(super) seq_base: u64,
|
||||||
|
pub(super) timed: bool,
|
||||||
|
/// Worker-lane CPU ns (when `timed`) and the seal outcome, filled in by the worker.
|
||||||
|
pub(super) ns: u64,
|
||||||
|
pub(super) result: Result<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistent second seal lane: a worker thread that AES-GCM-seals the back half of a
|
||||||
|
/// large frame's packets while the send thread seals the front half. Rendezvous channels
|
||||||
|
/// (bound 1) — the send thread submits, seals its half, then waits; no per-frame spawn.
|
||||||
|
/// Dropping the struct closes the channel and the worker exits.
|
||||||
|
pub(super) struct SealLane {
|
||||||
|
pub(super) to_worker: std::sync::mpsc::SyncSender<SealJob>,
|
||||||
|
pub(super) from_worker: std::sync::mpsc::Receiver<SealJob>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SealLane {
|
||||||
|
pub(super) fn spawn(crypto: std::sync::Arc<SessionCrypto>) -> Option<SealLane> {
|
||||||
|
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
||||||
|
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("punktfunk-seal2".into())
|
||||||
|
.spawn(move || {
|
||||||
|
while let Ok(mut job) = jobs.recv() {
|
||||||
|
let t0 = job.timed.then(std::time::Instant::now);
|
||||||
|
job.result = seal_wire_slice(&crypto, &mut job.bufs, job.seq_base);
|
||||||
|
if let Some(t0) = t0 {
|
||||||
|
job.ns = t0.elapsed().as_nanos() as u64;
|
||||||
|
}
|
||||||
|
if done_tx.send(job).is_err() {
|
||||||
|
break; // session gone mid-frame — nothing left to seal for
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
Some(SealLane {
|
||||||
|
to_worker,
|
||||||
|
from_worker,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seal a run of pre-written wire buffers in place: buffer `i` is `seq(8) ‖ plaintext ‖ tag
|
||||||
|
/// scratch` and seals over `[8..]` with sequence `seq_base + i` — the exact per-packet layout
|
||||||
|
/// and nonce order of the fused single-lane path. Shared by both lanes.
|
||||||
|
pub(super) fn seal_wire_slice(
|
||||||
|
c: &SessionCrypto,
|
||||||
|
wires: &mut [Vec<u8>],
|
||||||
|
seq_base: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
for (i, wire) in wires.iter_mut().enumerate() {
|
||||||
|
c.seal_in_place(seq_base.wrapping_add(i as u64), &mut wire[8..])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -198,8 +198,14 @@ pub(super) fn send_gso(t: &UdpTransport, packets: &[&[u8]]) -> std::io::Result<u
|
|||||||
return send_batch(t, packets);
|
return send_batch(t, packets);
|
||||||
}
|
}
|
||||||
let fd = t.socket.as_raw_fd();
|
let fd = t.socket.as_raw_fd();
|
||||||
// A GSO super-buffer is capped at 64 segments AND 65535 payload bytes (kernel limits).
|
// A GSO super-buffer is capped at 64 segments AND, in bytes, by the kernel's UDP payload
|
||||||
let max_seg = (65535 / seg).clamp(1, 64);
|
// ceiling. 65535 is the IP-datagram cap — the payload is that minus the IP + UDP headers
|
||||||
|
// the cork accounts for (IPv4: 65507, IPv6: 65487). Use the tighter v6 figure: it costs at
|
||||||
|
// most one segment per train, while a super-buffer over the ceiling is bounced with
|
||||||
|
// EMSGSIZE — which gso_unsupported() reads as "no GSO on this path" and latches GSO off
|
||||||
|
// process-wide, silently forfeiting the multi-Gbps lever over a local arithmetic slip.
|
||||||
|
const GSO_MAX_PAYLOAD: usize = 65535 - 40 - 8;
|
||||||
|
let max_seg = (GSO_MAX_PAYLOAD / seg).clamp(1, 64);
|
||||||
let mut scratch: Vec<u8> = Vec::with_capacity(seg * max_seg);
|
let mut scratch: Vec<u8> = Vec::with_capacity(seg * max_seg);
|
||||||
let mut sent = 0usize;
|
let mut sent = 0usize;
|
||||||
for chunk in packets.chunks(max_seg) {
|
for chunk in packets.chunks(max_seg) {
|
||||||
|
|||||||
@@ -187,34 +187,58 @@ pub fn detect_mul_slice() -> (MulSliceFn, MulSliceFn) {
|
|||||||
// Safe wrappers for SIMD functions (used as function pointer targets)
|
// Safe wrappers for SIMD functions (used as function pointer targets)
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_gfni_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_gfni_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_gfni_avx2(c, input, out) }
|
unsafe { mul_slice_gfni_avx2(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_xor_gfni_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_xor_gfni_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_xor_gfni_avx2(c, input, out) }
|
unsafe { mul_slice_xor_gfni_avx2(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_avx2(c, input, out) }
|
unsafe { mul_slice_avx2(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_xor_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_xor_avx2(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_xor_avx2(c, input, out) }
|
unsafe { mul_slice_xor_avx2(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_gfni_sse(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_gfni_sse(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_gfni_sse(c, input, out) }
|
unsafe { mul_slice_gfni_sse(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_xor_gfni_sse(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_xor_gfni_sse(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_xor_gfni_sse(c, input, out) }
|
unsafe { mul_slice_xor_gfni_sse(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_ssse3(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_ssse3(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_ssse3(c, input, out) }
|
unsafe { mul_slice_ssse3(c, input, out) }
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
fn wrap_mul_slice_xor_ssse3(c: u8, input: &[u8], out: &mut [u8]) {
|
fn wrap_mul_slice_xor_ssse3(c: u8, input: &[u8], out: &mut [u8]) {
|
||||||
|
// The unsafe callee bounds every load AND store on input.len(); unequal
|
||||||
|
// lengths would write past `out`. Same invariant mul_slice/mul_slice_xor enforce.
|
||||||
|
assert_eq!(input.len(), out.len());
|
||||||
unsafe { mul_slice_xor_ssse3(c, input, out) }
|
unsafe { mul_slice_xor_ssse3(c, input, out) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -624,8 +624,15 @@ impl ReedSolomon {
|
|||||||
for (i_input, &valid_idx) in valid_indices.iter().enumerate() {
|
for (i_input, &valid_idx) in valid_indices.iter().enumerate() {
|
||||||
// SAFETY: valid_idx and missing indices are disjoint sets,
|
// SAFETY: valid_idx and missing indices are disjoint sets,
|
||||||
// so we can safely read from valid_idx while writing to missing indices.
|
// so we can safely read from valid_idx while writing to missing indices.
|
||||||
let input_ptr = shards[valid_idx].get().unwrap().as_ptr();
|
// Pointer AND length from the same `get()` — an impl whose `len()`
|
||||||
let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, shard_len) };
|
// disagrees with its slice then panics in `mul_slice` instead of this
|
||||||
|
// fabricating an out-of-bounds slice from the trait-reported length.
|
||||||
|
let (input_ptr, input_len) = {
|
||||||
|
let input = shards[valid_idx].get().unwrap();
|
||||||
|
debug_assert_eq!(input.len(), shard_len);
|
||||||
|
(input.as_ptr(), input.len())
|
||||||
|
};
|
||||||
|
let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
|
||||||
|
|
||||||
for (i_out, &missing_idx) in missing_data_indices.iter().enumerate() {
|
for (i_out, &missing_idx) in missing_data_indices.iter().enumerate() {
|
||||||
let c = matrix_rows[i_out][i_input];
|
let c = matrix_rows[i_out][i_input];
|
||||||
@@ -659,8 +666,13 @@ impl ReedSolomon {
|
|||||||
|
|
||||||
for i_input in 0..self.data_shard_count {
|
for i_input in 0..self.data_shard_count {
|
||||||
// SAFETY: data shards (0..data_shard_count) are disjoint from parity shards.
|
// SAFETY: data shards (0..data_shard_count) are disjoint from parity shards.
|
||||||
let input_ptr = shards[i_input].get().unwrap().as_ptr();
|
// Same discipline as the data-shard loop above: length from the slice.
|
||||||
let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, shard_len) };
|
let (input_ptr, input_len) = {
|
||||||
|
let input = shards[i_input].get().unwrap();
|
||||||
|
debug_assert_eq!(input.len(), shard_len);
|
||||||
|
(input.as_ptr(), input.len())
|
||||||
|
};
|
||||||
|
let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
|
||||||
|
|
||||||
for (i_out, &missing_idx) in missing_parity_indices.iter().enumerate() {
|
for (i_out, &missing_idx) in missing_parity_indices.iter().enumerate() {
|
||||||
let c = matrix_rows[i_out][i_input];
|
let c = matrix_rows[i_out][i_input];
|
||||||
@@ -702,6 +714,10 @@ impl ReedSolomon {
|
|||||||
/// yield non-overlapping memory regions from `get()`/`get_mut()`. Specifically:
|
/// yield non-overlapping memory regions from `get()`/`get_mut()`. Specifically:
|
||||||
/// - `get()` on element `i` must not alias `get_mut()` on element `j` when `i != j`.
|
/// - `get()` on element `i` must not alias `get_mut()` on element `j` when `i != j`.
|
||||||
/// - The returned slices must remain valid and not be moved/reallocated while borrows are active.
|
/// - The returned slices must remain valid and not be moved/reallocated while borrows are active.
|
||||||
|
/// - `len()` must return `Some(n)` exactly when `get()`/`get_mut()` return `Some(s)`, and then
|
||||||
|
/// `s.len() == n`; after `initialize(n)`, `get_mut()` must return a slice of length `n`.
|
||||||
|
/// `reconstruct_internal` sizes its raw-pointer reads from `len()`, so a disagreement would
|
||||||
|
/// otherwise read past the allocation.
|
||||||
///
|
///
|
||||||
/// This is required because `reconstruct_internal` uses raw pointers to read from
|
/// This is required because `reconstruct_internal` uses raw pointers to read from
|
||||||
/// some shard indices while writing to others simultaneously.
|
/// some shard indices while writing to others simultaneously.
|
||||||
|
|||||||
@@ -1207,6 +1207,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
let mut cur_depth: usize = 1;
|
let mut cur_depth: usize = 1;
|
||||||
let mut behind_score: u32 = 0;
|
let mut behind_score: u32 = 0;
|
||||||
let mut depth_frames: u64 = 0;
|
let mut depth_frames: u64 = 0;
|
||||||
|
// Second escalation stage (§7 LN3): once depth is maxed (or was never available — Linux),
|
||||||
|
// ask the encoder for pipelined retrieve exactly once. Latched whether it accepts or not.
|
||||||
|
let mut pipeline_asked = false;
|
||||||
// ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips
|
// ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips
|
||||||
// the first ~1 s so bring-up (display acquire, encoder open) never triggers it.
|
// the first ~1 s so bring-up (display acquire, encoder open) never triggers it.
|
||||||
const DEPTH_ESCALATE: u32 = 20;
|
const DEPTH_ESCALATE: u32 = 20;
|
||||||
@@ -2056,10 +2059,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the
|
// Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the
|
||||||
// frame's work overran its cadence deadline `next`, so the trailing sleep would be
|
// frame's work overran its cadence deadline `next`, so the trailing sleep would be
|
||||||
// zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't
|
// zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't
|
||||||
// fit a frame interval — the contention case pipelining is for — so escalate to the
|
// fit a frame interval — the contention case pipelining is for — so escalate, and hold
|
||||||
// capturer's max and hold there. Leaky bucket + warmup skip reject one-off hitches and
|
// there. Leaky bucket + warmup skip reject one-off hitches and bring-up; no
|
||||||
// bring-up. Once escalated, `cur_depth` stays (no de-escalation in v1).
|
// de-escalation in v1. Two stages: first the CAPTURER's max depth (Windows IDD depth-2
|
||||||
if idd_adaptive_enabled() && cur_depth < max_depth {
|
// overlap); where depth can't grow (Linux portal is permanently depth-1, §7 LN3), the
|
||||||
|
// ENCODER's pipelined retrieve is the same trade on the other side of submit — the
|
||||||
|
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
|
||||||
|
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
|
||||||
|
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
|
||||||
|
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
|
||||||
depth_frames += 1;
|
depth_frames += 1;
|
||||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||||
let behind = std::time::Instant::now() >= next;
|
let behind = std::time::Instant::now() >= next;
|
||||||
@@ -2069,13 +2077,27 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
behind_score.saturating_sub(1)
|
behind_score.saturating_sub(1)
|
||||||
};
|
};
|
||||||
if behind_score >= DEPTH_ESCALATE {
|
if behind_score >= DEPTH_ESCALATE {
|
||||||
cur_depth = max_depth;
|
if cur_depth < max_depth {
|
||||||
tracing::info!(
|
cur_depth = max_depth;
|
||||||
depth = cur_depth,
|
tracing::info!(
|
||||||
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
depth = cur_depth,
|
||||||
(GPU contention); pipelining for the rest of the session (latency \
|
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
||||||
trade for throughput)"
|
(GPU contention); pipelining for the rest of the session (latency \
|
||||||
);
|
trade for throughput)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pipeline_asked = true;
|
||||||
|
if enc.set_pipelined(true) {
|
||||||
|
tracing::info!(
|
||||||
|
"encoder pipelined retrieve escalated — encode can't hold \
|
||||||
|
cadence and the capturer has no depth to give; the encode wait \
|
||||||
|
moves off the loop for the rest of the session (latency trade \
|
||||||
|
for throughput)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Give the action time to take effect before judging again.
|
||||||
|
behind_score = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@
|
|||||||
// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
|
// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
|
||||||
// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
|
// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
|
||||||
// is unchanged.
|
// is unchanged.
|
||||||
#define ABI_VERSION 9
|
// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||||
|
// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||||
|
// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||||
|
#define ABI_VERSION 10
|
||||||
|
|
||||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||||
@@ -110,8 +113,8 @@
|
|||||||
#define PUNKTFUNK_GAMEPAD_XBOX360 1
|
#define PUNKTFUNK_GAMEPAD_XBOX360 1
|
||||||
|
|
||||||
// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
||||||
// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored on
|
||||||
// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
// Linux (UHID) and Windows (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_DUALSENSE 2
|
#define PUNKTFUNK_GAMEPAD_DUALSENSE 2
|
||||||
|
|
||||||
// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
||||||
@@ -122,8 +125,8 @@
|
|||||||
|
|
||||||
// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
||||||
// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
||||||
// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
// DualSense (minus adaptive triggers / player LEDs / mute). Honored on Linux (UHID) and Windows
|
||||||
// hosts); otherwise the host falls back to X-Box 360.
|
// (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
||||||
|
|
||||||
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||||
@@ -136,11 +139,13 @@
|
|||||||
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
||||||
|
|
||||||
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
// client's back paddles land on native slots. Honored on Linux (UHID `hid-playstation`) and
|
||||||
|
// Windows (UMDF) hosts; otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_DUALSENSEEDGE 7
|
#define PUNKTFUNK_GAMEPAD_DUALSENSEEDGE 7
|
||||||
|
|
||||||
// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||||
// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
// positional layout, gyro/accel, HD rumble. Honored only where available (Linux hosts, UHID
|
||||||
|
// `hid-nintendo`); otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_SWITCHPRO 8
|
#define PUNKTFUNK_GAMEPAD_SWITCHPRO 8
|
||||||
|
|
||||||
// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
||||||
@@ -2215,6 +2220,20 @@ PunktfunkStatus punktfunk_connection_clock_offset_ns(const PunktfunkConnection *
|
|||||||
int64_t *offset_ns);
|
int64_t *offset_ns);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// The **live** host↔client wall-clock offset (nanoseconds, host minus client): the
|
||||||
|
// connect-time estimate of [`punktfunk_connection_clock_offset_ns`], updated by every applied
|
||||||
|
// mid-stream clock re-sync. Ongoing latency math (per-frame `received − pts` splits, the
|
||||||
|
// glass-to-glass meter) must use this one — after a wall-clock step/slew the frozen
|
||||||
|
// connect-time value reads tens of milliseconds wrong for the rest of the session, while the
|
||||||
|
// core itself has already re-synced. Same clock contract as the connect-time getter.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
|
||||||
|
PunktfunkStatus punktfunk_connection_clock_offset_now_ns(const PunktfunkConnection *c,
|
||||||
|
int64_t *offset_ns);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
||||||
// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ in
|
|||||||
dontFixup = true;
|
dontFixup = true;
|
||||||
outputHashMode = "recursive";
|
outputHashMode = "recursive";
|
||||||
outputHashAlgo = "sha256";
|
outputHashAlgo = "sha256";
|
||||||
outputHash = "sha256-OA4NjwapsCV/z+0rftDCMAQJGWw63Mi/GARetmuy0QU="; # web/bun.lock deps (refresh on lockfile change; see README).
|
outputHash = "sha256-5oVZv65SMvq9i2REzHE8Pyn6qUZaV2FnPQdaouwcwoU="; # web/bun.lock deps (refresh on lockfile change; see README).
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
@@ -326,6 +326,14 @@ in
|
|||||||
|
|
||||||
# No cross-derivation dep cache: codegen + the vite build are fully offline (every input is in
|
# No cross-derivation dep cache: codegen + the vite build are fully offline (every input is in
|
||||||
# the vendored node_modules, the checked-in api/openapi.json, and web/project.inlang).
|
# the vendored node_modules, the checked-in api/openapi.json, and web/project.inlang).
|
||||||
|
#
|
||||||
|
# ⚠ "Offline" is load-bearing and NOT self-enforcing. inlang resolves the plugins in
|
||||||
|
# web/project.inlang/settings.json `modules`, and a failed import is only a WARNING there:
|
||||||
|
# paraglide then prints "Successfully compiled", exits 0, and emits ZERO messages, so the
|
||||||
|
# console builds fine and dies at SSR time with every `m.foo()` undefined. That is exactly
|
||||||
|
# what a CDN URL in `modules` did in this network-less sandbox. The plugin is now a normal
|
||||||
|
# devDependency referenced by path, and `bun run codegen` ends in tools/check-i18n.mjs, which
|
||||||
|
# fails the build on a remote module or a short message count. Keep both properties.
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
export HOME=$TMPDIR
|
export HOME=$TMPDIR
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# @punktfunk/plugin-kit
|
||||||
|
|
||||||
|
The Effect-based framework punktfunk plugins are built on. It owns everything that is the
|
||||||
|
same in every plugin — lifecycle, config/state, the sync engine, UI serving, the CLI
|
||||||
|
scaffold, logging — so a plugin is just its domain logic, its HttpApi contract, and its UI.
|
||||||
|
The reference consumer (and the blueprint to copy) is
|
||||||
|
[`punktfunk-plugin-rom-manager`](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
|
||||||
|
|
||||||
|
Built on [`@punktfunk/host`](../sdk) (the SDK stays the low-level host client; the kit is
|
||||||
|
the opinionated plugin layer on top). Effect `4.x` and the SDK are peer dependencies —
|
||||||
|
the plugin's own copies are the only copies.
|
||||||
|
|
||||||
|
## The one rule: async at the boundary, Effect inside
|
||||||
|
|
||||||
|
The packaged runner bundles its own effect + SDK; a plugin's imports resolve to the
|
||||||
|
plugin's node_modules. Effect values must therefore never cross the plugin boundary
|
||||||
|
(`Context.Tag` identity is per-instance). `definePluginKit` enforces this by construction:
|
||||||
|
you write Effect, it exports a plain async-`main` `PluginDef`, and a `ManagedRuntime`
|
||||||
|
built from *your* effect instance runs everything. SIGINT/SIGTERM interrupt the plugin
|
||||||
|
fiber (scoped finalizers run: UI deregistration, watcher close), bounded by
|
||||||
|
`shutdownGraceMs`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
|
||||||
|
import { Effect, Layer } from "effect";
|
||||||
|
|
||||||
|
export default definePluginKit({
|
||||||
|
name: "my-plugin",
|
||||||
|
version: "0.1.0",
|
||||||
|
layer: MyServices.layer, // over the kit base: HostClient | PluginInfo
|
||||||
|
main: Effect.gen(function* () {
|
||||||
|
const engine = yield* MySync;
|
||||||
|
yield* engine.start;
|
||||||
|
yield* serveUi({ title: "My Plugin", icon: "puzzle", staticDir, api: MyApiLive });
|
||||||
|
yield* Effect.never;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Export | What it owns |
|
||||||
|
| --- | --- |
|
||||||
|
| `definePluginKit` / `runPluginKitDirect` | the async-main boundary + ManagedRuntime + signal handling |
|
||||||
|
| `HostClient`, `PluginInfo` | the `pf` facade as services (`request` = the skew-safe untyped seam) |
|
||||||
|
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
|
||||||
|
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
|
||||||
|
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire |
|
||||||
|
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
|
||||||
|
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
|
||||||
|
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
|
||||||
|
| `runPluginCli` | `<bin> <command>` dispatcher reusing the plugin's layer graph (deliberately not `effect/unstable/cli` — that would drag platform packages into every plugin) |
|
||||||
|
| `loggingLayer` | runner-journal line format |
|
||||||
|
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
|
||||||
|
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Tag `plugin-kit-vX.Y.Z` (matching `package.json`) — `.gitea/workflows/plugin-kit-publish.yml`
|
||||||
|
typechecks, tests, builds, and publishes to the Gitea registry.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@punktfunk/plugin-kit",
|
||||||
|
"devDependencies": {
|
||||||
|
"@punktfunk/host": "file:../sdk",
|
||||||
|
"@types/bun": "^1.3.0",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@punktfunk/host": "^0.1.2",
|
||||||
|
"effect": "^4.0.0-beta.98",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
},
|
||||||
|
"optionalPeers": [
|
||||||
|
"react",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
|
||||||
|
|
||||||
|
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
|
||||||
|
|
||||||
|
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.99", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.99" } }, "sha512-POBAowafsAAb3bH1x1rJlWnv32yMAazFgEuRW5LhkW/JJA5VGoEk9OnuoUkIH1OW6K/X6IrdNpqcO+5e9lPQJA=="],
|
||||||
|
|
||||||
|
"@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
|
||||||
|
|
||||||
|
"@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||||
|
|
||||||
|
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||||
|
|
||||||
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
|
||||||
|
|
||||||
|
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
|
"cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"effect": ["effect@4.0.0-beta.99", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-hP1C61uzINfLl/4kKMwcqksxd34s4sQ3VSjsWjhGrkx9CRlXaqnfOK9dpTEKynQ6rA7wU9rb3c+48eDYw7uzxA=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
|
||||||
|
|
||||||
|
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||||
|
|
||||||
|
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||||
|
|
||||||
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
|
"http2-client": ["http2-client@1.3.5", "", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="],
|
||||||
|
|
||||||
|
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||||
|
|
||||||
|
"ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
|
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||||
|
|
||||||
|
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
|
||||||
|
|
||||||
|
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||||
|
|
||||||
|
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
|
"node-fetch-h2": ["node-fetch-h2@2.3.0", "", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="],
|
||||||
|
|
||||||
|
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||||
|
|
||||||
|
"node-readfiles": ["node-readfiles@0.2.0", "", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="],
|
||||||
|
|
||||||
|
"oas-kit-common": ["oas-kit-common@1.0.8", "", { "dependencies": { "fast-safe-stringify": "^2.0.7" } }, "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ=="],
|
||||||
|
|
||||||
|
"oas-linter": ["oas-linter@3.2.2", "", { "dependencies": { "@exodus/schemasafe": "^1.0.0-rc.2", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ=="],
|
||||||
|
|
||||||
|
"oas-resolver": ["oas-resolver@2.5.6", "", { "dependencies": { "node-fetch-h2": "^2.3.0", "oas-kit-common": "^1.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "resolve": "resolve.js" } }, "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ=="],
|
||||||
|
|
||||||
|
"oas-schema-walker": ["oas-schema-walker@1.1.5", "", {}, "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ=="],
|
||||||
|
|
||||||
|
"oas-validator": ["oas-validator@5.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "oas-kit-common": "^1.0.8", "oas-linter": "^3.2.2", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "reftools": "^1.1.9", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw=="],
|
||||||
|
|
||||||
|
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
|
||||||
|
|
||||||
|
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||||
|
|
||||||
|
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||||
|
|
||||||
|
"reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="],
|
||||||
|
|
||||||
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
|
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
|
||||||
|
|
||||||
|
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
|
||||||
|
|
||||||
|
"should-format": ["should-format@3.0.3", "", { "dependencies": { "should-type": "^1.3.0", "should-type-adaptors": "^1.0.1" } }, "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q=="],
|
||||||
|
|
||||||
|
"should-type": ["should-type@1.4.0", "", {}, "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ=="],
|
||||||
|
|
||||||
|
"should-type-adaptors": ["should-type-adaptors@1.1.0", "", { "dependencies": { "should-type": "^1.3.0", "should-util": "^1.0.0" } }, "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA=="],
|
||||||
|
|
||||||
|
"should-util": ["should-util@1.0.1", "", {}, "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g=="],
|
||||||
|
|
||||||
|
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"swagger2openapi": ["swagger2openapi@7.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "swagger2openapi": "swagger2openapi.js", "oas-validate": "oas-validate.js", "boast": "boast.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="],
|
||||||
|
|
||||||
|
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
|
||||||
|
|
||||||
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||||
|
|
||||||
|
"yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
|
||||||
|
|
||||||
|
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||||
|
|
||||||
|
"@effect/platform-node/undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
|
||||||
|
|
||||||
|
"oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||||
|
|
||||||
|
"oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||||
|
|
||||||
|
"oas-validator/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||||
|
|
||||||
|
"swagger2openapi/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Resolve the `@punktfunk` scope from the Gitea npm registry. During local development the
|
||||||
|
# SDK is consumed via the `link:../sdk` devDependency; this matters once the kit is depended
|
||||||
|
# on from outside the repo.
|
||||||
|
[install.scopes]
|
||||||
|
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@punktfunk/plugin-kit",
|
||||||
|
"version": "0.1.4",
|
||||||
|
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"homepage": "https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.unom.io/unom/punktfunk.git",
|
||||||
|
"directory": "plugin-kit"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.unom.io/unom/punktfunk/issues"
|
||||||
|
},
|
||||||
|
"keywords": ["punktfunk", "plugin", "framework", "effect"],
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./react": {
|
||||||
|
"types": "./dist/react/index.d.ts",
|
||||||
|
"default": "./dist/react/index.js"
|
||||||
|
},
|
||||||
|
"./wire": {
|
||||||
|
"types": "./dist/wire.d.ts",
|
||||||
|
"default": "./dist/wire.js"
|
||||||
|
},
|
||||||
|
"./theme.css": "./dist/theme.css"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "tsc -p tsconfig.build.json && cp src/theme.css dist/theme.css",
|
||||||
|
"test": "bun test",
|
||||||
|
"prepublishOnly": "bun run build"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"effect": "^4.0.0-beta.98",
|
||||||
|
"@punktfunk/host": "^0.1.2",
|
||||||
|
"react": "^19.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@punktfunk/host": "file:../sdk",
|
||||||
|
"@types/bun": "^1.3.0",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Disposable derived state (cache.json): corrupt or absent falls back to `empty` and
|
||||||
|
// never fails a read; every `modify` is write-through with the same atomic-write
|
||||||
|
// discipline as config. Held in a Ref so reads are cheap and mutations are ordered.
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { Effect, Ref, Schema } from "effect";
|
||||||
|
import type { ConfigWriteError } from "./errors.js";
|
||||||
|
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
||||||
|
import { PluginInfo } from "./host-client.js";
|
||||||
|
|
||||||
|
export interface CacheStore<S extends Schema.Top> {
|
||||||
|
readonly get: Effect.Effect<S["Type"]>;
|
||||||
|
/** Atomically update the cache (Ref + write-through). Returns the `modify` result. */
|
||||||
|
readonly modify: <A>(
|
||||||
|
f: (current: S["Type"]) => readonly [A, S["Type"]],
|
||||||
|
) => Effect.Effect<A, ConfigWriteError>;
|
||||||
|
/** `modify` without a result. */
|
||||||
|
readonly update: (
|
||||||
|
f: (current: S["Type"]) => S["Type"],
|
||||||
|
) => Effect.Effect<void, ConfigWriteError>;
|
||||||
|
/** Absolute path of the cache file (status views). */
|
||||||
|
readonly path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeCacheStore = <S extends Schema.Top>(opts: {
|
||||||
|
readonly schema: S;
|
||||||
|
readonly empty: S["Type"];
|
||||||
|
readonly fileName?: string;
|
||||||
|
}): Effect.Effect<CacheStore<S>, never, PluginInfo> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const info = yield* PluginInfo;
|
||||||
|
const file = statePath(info.name, opts.fileName ?? "cache.json");
|
||||||
|
|
||||||
|
const initial = yield* Effect.suspend(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
||||||
|
return Schema.decodeUnknownEffect(opts.schema)(parsed).pipe(
|
||||||
|
Effect.orElseSucceed(() => opts.empty),
|
||||||
|
) as Effect.Effect<S["Type"]>;
|
||||||
|
} catch {
|
||||||
|
return Effect.succeed(opts.empty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const ref = yield* Ref.make<S["Type"]>(initial);
|
||||||
|
|
||||||
|
const persist = (value: S["Type"]) =>
|
||||||
|
ensureStateDir(info.name).pipe(
|
||||||
|
Effect.flatMap(() =>
|
||||||
|
atomicWriteFile(file, JSON.stringify(value)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const modify = <A>(f: (current: S["Type"]) => readonly [A, S["Type"]]) =>
|
||||||
|
Ref.modify(ref, (current) => {
|
||||||
|
const [a, next] = f(current);
|
||||||
|
return [[a, next] as const, next] as const;
|
||||||
|
}).pipe(
|
||||||
|
Effect.flatMap(([a, next]) =>
|
||||||
|
persist(next).pipe(Effect.as(a)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: Ref.get(ref),
|
||||||
|
modify,
|
||||||
|
update: (f) => modify((c) => [undefined, f(c)] as const),
|
||||||
|
path: file,
|
||||||
|
} satisfies CacheStore<S>;
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Minimal plugin CLI scaffold. Deliberately NOT `effect/unstable/cli`: its runner needs
|
||||||
|
// Stdio/Terminal/FileSystem service implementations that only ship in platform packages,
|
||||||
|
// which would add a runtime dependency to every plugin for what is a five-verb ops tool.
|
||||||
|
// A plugin CLI is `<bin> <command> [args...]` — this dispatcher gives that shape the same
|
||||||
|
// ManagedRuntime + layer graph as the plugin entry, so commands reuse the exact services.
|
||||||
|
import { connect, type Punktfunk } from "@punktfunk/host";
|
||||||
|
import { Effect, Layer, ManagedRuntime } from "effect";
|
||||||
|
import {
|
||||||
|
type HostClient,
|
||||||
|
hostClientFromFacade,
|
||||||
|
type PluginInfo,
|
||||||
|
pluginInfoLayer,
|
||||||
|
} from "./host-client.js";
|
||||||
|
import { HostRequestError } from "./errors.js";
|
||||||
|
import { loggingLayer } from "./logging.js";
|
||||||
|
import type { PluginKitDef } from "./runtime.js";
|
||||||
|
|
||||||
|
export interface CliCommand<R> {
|
||||||
|
readonly summary: string;
|
||||||
|
/** Set when the command works without a running host (scan/preview style). */
|
||||||
|
readonly offline?: boolean;
|
||||||
|
readonly run: (
|
||||||
|
argv: ReadonlyArray<string>,
|
||||||
|
) => Effect.Effect<void, unknown, R | HostClient | PluginInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A HostClient whose calls fail — the offline lane for host-free commands. */
|
||||||
|
const offlineFacade = (name: string): Punktfunk =>
|
||||||
|
({
|
||||||
|
request: async (method: string, path: string) => {
|
||||||
|
throw new Error(
|
||||||
|
`${name}: this command ran offline but tried ${method} ${path} — is the host running?`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close: () => {},
|
||||||
|
}) as unknown as Punktfunk;
|
||||||
|
|
||||||
|
const usage = <R>(
|
||||||
|
def: { name: string; version?: string },
|
||||||
|
commands: Record<string, CliCommand<R>>,
|
||||||
|
): string => {
|
||||||
|
const rows = Object.entries(commands)
|
||||||
|
.map(([cmd, c]) => ` ${cmd.padEnd(12)} ${c.summary}`)
|
||||||
|
.join("\n");
|
||||||
|
return `${def.name}${def.version ? ` ${def.version}` : ""}\n\nUsage: punktfunk-plugin-${def.name} <command> [args...]\n\nCommands:\n${rows}\n`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one CLI invocation: dispatch `process.argv[2]`, build the plugin's layer graph,
|
||||||
|
* run the command, tear down. Exits the process (0 ok / 1 failure / 2 usage).
|
||||||
|
*/
|
||||||
|
export const runPluginCli = async <E, R>(opts: {
|
||||||
|
readonly def: PluginKitDef<E, R>;
|
||||||
|
readonly commands: Record<string, CliCommand<R>>;
|
||||||
|
readonly argv?: ReadonlyArray<string>;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const argv = opts.argv ?? process.argv.slice(2);
|
||||||
|
const [name, ...rest] = argv;
|
||||||
|
const command = name ? opts.commands[name] : undefined;
|
||||||
|
if (!command) {
|
||||||
|
console.log(usage(opts.def, opts.commands));
|
||||||
|
process.exit(name === undefined || name === "help" ? 0 : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pf = command.offline
|
||||||
|
? offlineFacade(opts.def.name)
|
||||||
|
: await connect();
|
||||||
|
const base = Layer.mergeAll(
|
||||||
|
hostClientFromFacade(pf),
|
||||||
|
pluginInfoLayer({ name: opts.def.name, version: opts.def.version }),
|
||||||
|
loggingLayer(opts.def.name),
|
||||||
|
);
|
||||||
|
const rt = ManagedRuntime.make(Layer.provideMerge(opts.def.layer, base));
|
||||||
|
try {
|
||||||
|
await rt.runPromise(Effect.scoped(command.run(rest)));
|
||||||
|
process.exitCode = 0;
|
||||||
|
} catch (e) {
|
||||||
|
const hint =
|
||||||
|
e instanceof HostRequestError
|
||||||
|
? " (is the punktfunk host running?)"
|
||||||
|
: "";
|
||||||
|
console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await rt.dispose();
|
||||||
|
pf.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Schema-driven plugin config with raw round-trip semantics.
|
||||||
|
//
|
||||||
|
// The invariant that kills the raw-vs-resolved muddle of the first-generation plugins:
|
||||||
|
// the file on disk is ALWAYS the operator-authored (raw / Encoded) shape; defaults live
|
||||||
|
// ONLY in the Schema (`Schema.withDecodingDefaultKey(..., { encodingStrategy: "omit" })`)
|
||||||
|
// and are applied at decode time. `saveRaw` validates by decoding but persists the raw
|
||||||
|
// shape verbatim — a UI save never bakes defaults into the file.
|
||||||
|
//
|
||||||
|
// Ported semantics from rom-manager's state.ts: state dir 0700, atomic temp+rename 0600
|
||||||
|
// writes, POSIX group/world-writable refusal (config controls commands run as the host
|
||||||
|
// user), missing file == empty config.
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { Effect, PubSub, Schema, Stream } from "effect";
|
||||||
|
import {
|
||||||
|
ConfigParseError,
|
||||||
|
ConfigPermissionError,
|
||||||
|
type ConfigWriteError,
|
||||||
|
} from "./errors.js";
|
||||||
|
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
||||||
|
import { PluginInfo } from "./host-client.js";
|
||||||
|
|
||||||
|
export interface ConfigService<S extends Schema.Top> {
|
||||||
|
/** Decode the raw file with Schema defaults applied. Missing file → all defaults. */
|
||||||
|
readonly load: Effect.Effect<
|
||||||
|
S["Type"],
|
||||||
|
ConfigParseError | ConfigPermissionError
|
||||||
|
>;
|
||||||
|
/** The operator-authored shape, validated but NOT defaulted — what the UI edits. */
|
||||||
|
readonly loadRaw: Effect.Effect<
|
||||||
|
S["Encoded"],
|
||||||
|
ConfigParseError | ConfigPermissionError
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* Validate-by-decode, persist the RAW shape verbatim (atomic), emit the decoded
|
||||||
|
* config on `changes`, and return it.
|
||||||
|
*/
|
||||||
|
readonly saveRaw: (
|
||||||
|
raw: unknown,
|
||||||
|
) => Effect.Effect<
|
||||||
|
S["Type"],
|
||||||
|
ConfigParseError | ConfigWriteError
|
||||||
|
>;
|
||||||
|
/** Emits the decoded config after every successful `saveRaw`. */
|
||||||
|
readonly changes: Stream.Stream<S["Type"]>;
|
||||||
|
/** Absolute path of the config file (status views). */
|
||||||
|
readonly path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refuse a group/world-writable config file (POSIX only; Windows state dir is DACL'd). */
|
||||||
|
const checkNotWorldWritable = (
|
||||||
|
file: string,
|
||||||
|
): Effect.Effect<void, ConfigPermissionError> =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
if (process.platform === "win32") return Effect.void;
|
||||||
|
let mode: number;
|
||||||
|
try {
|
||||||
|
mode = fs.statSync(file).mode;
|
||||||
|
} catch {
|
||||||
|
return Effect.void; // absent — nothing to guard
|
||||||
|
}
|
||||||
|
return (mode & 0o022) !== 0
|
||||||
|
? Effect.fail(new ConfigPermissionError({ path: file, mode }))
|
||||||
|
: Effect.void;
|
||||||
|
});
|
||||||
|
|
||||||
|
const readRawObject = (
|
||||||
|
file: string,
|
||||||
|
): Effect.Effect<unknown, ConfigParseError | ConfigPermissionError> =>
|
||||||
|
checkNotWorldWritable(file).pipe(
|
||||||
|
Effect.flatMap(() =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(file, "utf8");
|
||||||
|
} catch {
|
||||||
|
return Effect.succeed({} as unknown); // missing file == empty config
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Effect.succeed(JSON.parse(text) as unknown);
|
||||||
|
} catch (e) {
|
||||||
|
return Effect.fail(
|
||||||
|
new ConfigParseError({ path: file, issue: String(e) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const makeConfigService = <S extends Schema.Top>(opts: {
|
||||||
|
readonly schema: S;
|
||||||
|
readonly fileName?: string;
|
||||||
|
}): Effect.Effect<ConfigService<S>, never, PluginInfo> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const info = yield* PluginInfo;
|
||||||
|
const file = statePath(info.name, opts.fileName ?? "config.json");
|
||||||
|
const hub = yield* PubSub.unbounded<S["Type"]>();
|
||||||
|
|
||||||
|
const decode = (
|
||||||
|
raw: unknown,
|
||||||
|
): Effect.Effect<S["Type"], ConfigParseError> =>
|
||||||
|
Schema.decodeUnknownEffect(opts.schema)(raw).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(e) => new ConfigParseError({ path: file, issue: String(e) }),
|
||||||
|
),
|
||||||
|
) as Effect.Effect<S["Type"], ConfigParseError>;
|
||||||
|
|
||||||
|
const load = readRawObject(file).pipe(Effect.flatMap(decode));
|
||||||
|
|
||||||
|
// Validated (a broken file must not masquerade as authored config), returned verbatim.
|
||||||
|
const loadRaw = readRawObject(file).pipe(
|
||||||
|
Effect.tap(decode),
|
||||||
|
Effect.map((raw) => raw as S["Encoded"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveRaw = (raw: unknown) =>
|
||||||
|
decode(raw).pipe(
|
||||||
|
Effect.tap(() => ensureStateDir(info.name)),
|
||||||
|
Effect.tap(() =>
|
||||||
|
atomicWriteFile(file, `${JSON.stringify(raw, null, 2)}\n`),
|
||||||
|
),
|
||||||
|
Effect.tap((decoded) => PubSub.publish(hub, decoded)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
load,
|
||||||
|
loadRaw,
|
||||||
|
saveRaw,
|
||||||
|
changes: Stream.fromPubSub(hub),
|
||||||
|
path: file,
|
||||||
|
} satisfies ConfigService<S>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A `Context.Service` class factory is deliberately NOT provided — plugins define their
|
||||||
|
// own service key over `ConfigService<their schema>` so the config type stays precise:
|
||||||
|
//
|
||||||
|
// class RomConfig extends Context.Service<RomConfig, ConfigService<typeof RomConfigSchema>>()(
|
||||||
|
// "rom-manager/Config",
|
||||||
|
// ) {
|
||||||
|
// static layer = Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema }))
|
||||||
|
// }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Kit-level error taxonomy. `Data.TaggedError` (matching the SDK's idiom in
|
||||||
|
// sdk/src/client.ts) — these never cross HTTP; a plugin's UI-API contract defines its own
|
||||||
|
// Schema-based errors with status annotations.
|
||||||
|
import { Data } from "effect";
|
||||||
|
|
||||||
|
/** A management-API call through the pf facade failed. */
|
||||||
|
export class HostRequestError extends Data.TaggedError("HostRequestError")<{
|
||||||
|
readonly method: string;
|
||||||
|
readonly path: string;
|
||||||
|
readonly cause: unknown;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
/** config.json exists but does not parse/decode. */
|
||||||
|
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
|
||||||
|
readonly path: string;
|
||||||
|
readonly issue: string;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* config.json is group/world-writable (POSIX). This file controls commands run as the
|
||||||
|
* host user, so the kit refuses it — the same sshd rule the runner applies to unit files.
|
||||||
|
*/
|
||||||
|
export class ConfigPermissionError extends Data.TaggedError(
|
||||||
|
"ConfigPermissionError",
|
||||||
|
)<{
|
||||||
|
readonly path: string;
|
||||||
|
readonly mode: number;
|
||||||
|
}> {
|
||||||
|
override get message(): string {
|
||||||
|
return `refusing ${this.path}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisting config/state failed. */
|
||||||
|
export class ConfigWriteError extends Data.TaggedError("ConfigWriteError")<{
|
||||||
|
readonly path: string;
|
||||||
|
readonly cause: unknown;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
/** The plugin UI server could not be started/registered. */
|
||||||
|
export class UiServeError extends Data.TaggedError("UiServeError")<{
|
||||||
|
readonly cause: unknown;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
/** A sync pass failed (compute or apply). */
|
||||||
|
export class SyncError extends Data.TaggedError("SyncError")<{
|
||||||
|
readonly reason: string;
|
||||||
|
readonly cause: unknown;
|
||||||
|
}> {}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// The pf seam as Effect services. `HostClient.request` keeps the SDK's deliberately
|
||||||
|
// untyped `pf.request` wire (version-skew-safe against the runner-bundled SDK — design D7);
|
||||||
|
// typing happens one level up (reconcile.ts owns the wire schemas). Only the plain `pf`
|
||||||
|
// object crosses the plugin boundary, so no Effect values are shared with the runner's
|
||||||
|
// bundled effect instance.
|
||||||
|
import type { Punktfunk } from "@punktfunk/host";
|
||||||
|
import { Context, Effect, Layer } from "effect";
|
||||||
|
import { HostRequestError } from "./errors.js";
|
||||||
|
|
||||||
|
export interface HostClientService {
|
||||||
|
/** Raw management-API call (loopback + bearer, via the SDK facade). */
|
||||||
|
readonly request: (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
) => Effect.Effect<unknown, HostRequestError>;
|
||||||
|
/**
|
||||||
|
* Escape hatch for SDK helpers that take the facade directly (`servePluginUi`).
|
||||||
|
* Never share this with code that could leak Effect values back to the runner.
|
||||||
|
*/
|
||||||
|
readonly facade: Punktfunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HostClient extends Context.Service<HostClient, HostClientService>()(
|
||||||
|
"@punktfunk/plugin-kit/HostClient",
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Wrap the facade the runner hands to `main` (or `connect()` in the CLI/dev paths). */
|
||||||
|
export const hostClientFromFacade = (
|
||||||
|
pf: Punktfunk,
|
||||||
|
): Layer.Layer<HostClient> =>
|
||||||
|
Layer.succeed(HostClient)({
|
||||||
|
request: (method, path, body) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => pf.request(method, path, body),
|
||||||
|
catch: (cause) => new HostRequestError({ method, path, cause }),
|
||||||
|
}),
|
||||||
|
facade: pf,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface PluginInfoService {
|
||||||
|
/** The plugin id (`definePlugin` name, `[a-z][a-z0-9-]*`). */
|
||||||
|
readonly name: string;
|
||||||
|
readonly version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginInfo extends Context.Service<PluginInfo, PluginInfoService>()(
|
||||||
|
"@punktfunk/plugin-kit/PluginInfo",
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export const pluginInfoLayer = (
|
||||||
|
info: PluginInfoService,
|
||||||
|
): Layer.Layer<PluginInfo> => Layer.succeed(PluginInfo)(info);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// @punktfunk/plugin-kit — Effect-based framework for punktfunk plugins.
|
||||||
|
export * from "./errors.js";
|
||||||
|
export {
|
||||||
|
atomicWriteFile,
|
||||||
|
ensureStateDir,
|
||||||
|
pluginIngestDir,
|
||||||
|
pluginStateDir,
|
||||||
|
statePath,
|
||||||
|
} from "./paths.js";
|
||||||
|
export {
|
||||||
|
HostClient,
|
||||||
|
hostClientFromFacade,
|
||||||
|
type HostClientService,
|
||||||
|
PluginInfo,
|
||||||
|
pluginInfoLayer,
|
||||||
|
type PluginInfoService,
|
||||||
|
} from "./host-client.js";
|
||||||
|
export { loggingLayer } from "./logging.js";
|
||||||
|
export { type ConfigService, makeConfigService } from "./config.js";
|
||||||
|
export { type CacheStore, makeCacheStore } from "./cache-store.js";
|
||||||
|
export {
|
||||||
|
Artwork,
|
||||||
|
LaunchSpec,
|
||||||
|
PrepStep,
|
||||||
|
ProviderClient,
|
||||||
|
type ProviderClientService,
|
||||||
|
ProviderEntry,
|
||||||
|
} from "./reconcile.js";
|
||||||
|
export {
|
||||||
|
definePluginKit,
|
||||||
|
type PluginKitDef,
|
||||||
|
runPluginKitDirect,
|
||||||
|
} from "./runtime.js";
|
||||||
|
export {
|
||||||
|
type LastSync,
|
||||||
|
makeSyncEngine,
|
||||||
|
type SyncEngine,
|
||||||
|
type SyncEngineOptions,
|
||||||
|
type SyncOutcome,
|
||||||
|
type SyncReason,
|
||||||
|
type SyncSettings,
|
||||||
|
type SyncStatus,
|
||||||
|
} from "./sync-engine.js";
|
||||||
|
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
|
||||||
|
export { sseRoute, type SseRouteOptions } from "./sse.js";
|
||||||
|
export { type CliCommand, runPluginCli } from "./cli.js";
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Runner-journal logging: one stamped line per log call, `<ISO> [<plugin>] <message>`,
|
||||||
|
// matching the format the scripting runner journals (and what the previous hand-rolled
|
||||||
|
// plugin loggers emitted), so kit-based plugins read consistently in
|
||||||
|
// `journalctl --user -u punktfunk-scripting`.
|
||||||
|
import { Cause, Layer, Logger } from "effect";
|
||||||
|
|
||||||
|
const render = (message: unknown): string => {
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
if (Array.isArray(message)) return message.map(render).join(" ");
|
||||||
|
return typeof message === "object" ? JSON.stringify(message) : String(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replace the default logger with the runner-journal format. */
|
||||||
|
export const loggingLayer = (plugin: string): Layer.Layer<never> =>
|
||||||
|
Logger.layer([
|
||||||
|
Logger.make(({ message, logLevel, cause, date }) => {
|
||||||
|
const level = logLevel === "Info" ? "" : ` ${logLevel.toUpperCase()}:`;
|
||||||
|
const causeSuffix =
|
||||||
|
cause.reasons.length > 0 ? ` ${Cause.pretty(cause)}` : "";
|
||||||
|
console.log(
|
||||||
|
`${date.toISOString()} [${plugin}]${level} ${render(message)}${causeSuffix}`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Filesystem seam: the SDK owns the canonical path layout (plugin-state, ingest); the kit
|
||||||
|
// re-exports those and adds the two write primitives every plugin needs — a 0700 state dir
|
||||||
|
// and atomic (temp + rename, 0600) file writes. All IO is node:fs, wrapped in Effect.
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { pluginIngestDir, pluginStateDir } from "@punktfunk/host";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { ConfigWriteError } from "./errors.js";
|
||||||
|
|
||||||
|
export { pluginIngestDir, pluginStateDir };
|
||||||
|
|
||||||
|
/** Create `pluginStateDir(name)` 0700 if absent (idempotent). Returns the dir. */
|
||||||
|
export const ensureStateDir = (
|
||||||
|
name: string,
|
||||||
|
): Effect.Effect<string, ConfigWriteError> =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => {
|
||||||
|
const dir = pluginStateDir(name);
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
return dir;
|
||||||
|
},
|
||||||
|
catch: (cause) =>
|
||||||
|
new ConfigWriteError({ path: pluginStateDir(name), cause }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic write: temp file (0600) in the same dir, then rename — a crash never leaves a
|
||||||
|
* torn file, and readers only ever see complete content.
|
||||||
|
*/
|
||||||
|
export const atomicWriteFile = (
|
||||||
|
file: string,
|
||||||
|
data: string,
|
||||||
|
): Effect.Effect<void, ConfigWriteError> =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => {
|
||||||
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
||||||
|
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
},
|
||||||
|
catch: (cause) => new ConfigWriteError({ path: file, cause }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Join inside a plugin's state dir. */
|
||||||
|
export const statePath = (name: string, file: string): string =>
|
||||||
|
path.join(pluginStateDir(name), file);
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Browser/React glue for plugin UIs served through the console's /plugin-ui/<id>/ proxy.
|
||||||
|
// Design-system-free on purpose: ResultGate takes render props (the plugin wraps it once
|
||||||
|
// with its @unom/ui skeleton/error visuals), so the kit's only peer here is react.
|
||||||
|
//
|
||||||
|
// Routing model (fixes the broken deep-link restore of the first-generation UIs): the
|
||||||
|
// console pins the iframe src to the deep-linked PATH (`/plugin-ui/<id>/<route>`), so
|
||||||
|
// route init must read the last pathname segment — the hash is only a standalone-tab
|
||||||
|
// fallback. Navigation posts `pf-ui:navigate` so the console mirrors the route into its
|
||||||
|
// own URL (replace: true; the iframe src stays pinned — no reload loop).
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { Option, Schema } from "effect";
|
||||||
|
import { AsyncResult, Atom } from "effect/unstable/reactivity";
|
||||||
|
|
||||||
|
/** `/plugin-ui/<id>` when served through the console proxy, "" in dev/standalone. */
|
||||||
|
export const resolvePluginBase = (): string => {
|
||||||
|
const m = window.location.pathname.match(/^\/plugin-ui\/[a-z][a-z0-9-]*/);
|
||||||
|
return m ? m[0] : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when running inside the console's iframe. */
|
||||||
|
export const useIsEmbedded = (): boolean =>
|
||||||
|
typeof window !== "undefined" && window.parent !== window;
|
||||||
|
|
||||||
|
/** Mirror a route into the console's address bar (best-effort, embedded only). */
|
||||||
|
export const postNavigate = (path: string): void => {
|
||||||
|
try {
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type: "pf-ui:navigate", path }, "*");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cross-origin parent or detached — deep-link sync is best-effort
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialRoute = <Route extends string>(
|
||||||
|
routes: ReadonlyArray<Route>,
|
||||||
|
fallback: Route,
|
||||||
|
): Route => {
|
||||||
|
const isRoute = (s: string | undefined): s is Route =>
|
||||||
|
s !== undefined && (routes as ReadonlyArray<string>).includes(s);
|
||||||
|
const lastSegment = window.location.pathname
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.at(-1);
|
||||||
|
if (isRoute(lastSegment)) return lastSegment;
|
||||||
|
const hashSegment = window.location.hash.replace(/^#\/?/, "");
|
||||||
|
if (isRoute(hashSegment)) return hashSegment;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat single-segment routes, no router library. Returns a hook: route state initialized
|
||||||
|
* from path-then-hash, `navigate` updates state + hash (standalone back/forward) + the
|
||||||
|
* console deep-link bridge. Listens to hashchange for browser navigation.
|
||||||
|
*/
|
||||||
|
export const createPluginRouter = <const Routes extends ReadonlyArray<string>>(
|
||||||
|
routes: Routes,
|
||||||
|
fallback: Routes[number],
|
||||||
|
) => {
|
||||||
|
type Route = Routes[number];
|
||||||
|
const usePluginRoute = (): {
|
||||||
|
route: Route;
|
||||||
|
navigate: (r: Route) => void;
|
||||||
|
} => {
|
||||||
|
const [route, setRoute] = useState<Route>(() =>
|
||||||
|
initialRoute(routes as ReadonlyArray<Route>, fallback as Route),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const onHash = () => {
|
||||||
|
const seg = window.location.hash.replace(/^#\/?/, "");
|
||||||
|
if ((routes as ReadonlyArray<string>).includes(seg)) {
|
||||||
|
setRoute(seg as Route);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", onHash);
|
||||||
|
return () => window.removeEventListener("hashchange", onHash);
|
||||||
|
}, []);
|
||||||
|
const navigate = (r: Route) => {
|
||||||
|
setRoute(r);
|
||||||
|
window.location.hash = `/${r}`;
|
||||||
|
postNavigate(r);
|
||||||
|
};
|
||||||
|
return { route, navigate };
|
||||||
|
};
|
||||||
|
return { routes, usePluginRoute };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResultGateProps<A, E> {
|
||||||
|
readonly result: AsyncResult.AsyncResult<A, E>;
|
||||||
|
/** Rendered while the first value loads (page skeleton). */
|
||||||
|
readonly waiting?: ReactNode;
|
||||||
|
/** Rendered on failure; `retry` re-triggers via the caller (registry refresh). */
|
||||||
|
readonly failure?: (error: E, retry?: () => void) => ReactNode;
|
||||||
|
readonly retry?: () => void;
|
||||||
|
readonly children: (value: A) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one loading/error/success convention for plugin pages. Keeps showing the last
|
||||||
|
* value while a refresh is in flight (no skeleton flash on invalidation).
|
||||||
|
*/
|
||||||
|
export const ResultGate = <A, E>(
|
||||||
|
props: ResultGateProps<A, E>,
|
||||||
|
): ReactNode => {
|
||||||
|
const { result } = props;
|
||||||
|
if (AsyncResult.isSuccess(result)) return props.children(result.value);
|
||||||
|
if (AsyncResult.isFailure(result)) {
|
||||||
|
const error = Option.getOrUndefined(AsyncResult.error(result)) as E;
|
||||||
|
return props.failure?.(error, props.retry) ?? null;
|
||||||
|
}
|
||||||
|
// Initial or waiting-without-value.
|
||||||
|
return props.waiting ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SseAtomOptions<A, I> {
|
||||||
|
/** Absolute-or-relative URL of the SSE endpoint (prefix it via resolvePluginBase). */
|
||||||
|
readonly url: string;
|
||||||
|
/** SSE `event:` name (default "message"). */
|
||||||
|
readonly event?: string;
|
||||||
|
readonly schema: Schema.Codec<A, I>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Atom over a reconnecting EventSource: emits each schema-valid frame, `undefined`
|
||||||
|
* until the first one. The browser reconnects EventSource automatically; the atom closes
|
||||||
|
* it when the last subscriber unmounts.
|
||||||
|
*/
|
||||||
|
export const sseAtom = <A, I>(
|
||||||
|
opts: SseAtomOptions<A, I>,
|
||||||
|
): Atom.Atom<A | undefined> =>
|
||||||
|
Atom.make<A | undefined>((get) => {
|
||||||
|
const source = new EventSource(opts.url);
|
||||||
|
const decode = Schema.decodeUnknownSync(opts.schema);
|
||||||
|
source.addEventListener(opts.event ?? "message", (e) => {
|
||||||
|
try {
|
||||||
|
get.setSelf(decode(JSON.parse((e as MessageEvent).data)));
|
||||||
|
} catch {
|
||||||
|
// skip schema-invalid frames (version skew tolerance)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
get.addFinalizer(() => source.close());
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// The library-provider client, owned by the kit so plugins stop hand-copying host calls.
|
||||||
|
// The wire SCHEMAS live in ./wire.ts (browser-safe — plugin contracts re-use them); the
|
||||||
|
// transport here stays the SDK's untyped `pf.request` seam (version-skew-safe under the
|
||||||
|
// runner-bundled SDK — design D7).
|
||||||
|
import { Context, Effect, Layer } from "effect";
|
||||||
|
import type { HostRequestError } from "./errors.js";
|
||||||
|
import { HostClient } from "./host-client.js";
|
||||||
|
import type { ProviderEntry } from "./wire.js";
|
||||||
|
|
||||||
|
export * from "./wire.js";
|
||||||
|
|
||||||
|
export interface ProviderClientService {
|
||||||
|
/** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */
|
||||||
|
readonly reconcile: (
|
||||||
|
providerId: string,
|
||||||
|
entries: ReadonlyArray<ProviderEntry>,
|
||||||
|
) => Effect.Effect<void, HostRequestError>;
|
||||||
|
/** Remove every entry this provider owns (the explicit-uninstall path). */
|
||||||
|
readonly remove: (providerId: string) => Effect.Effect<void, HostRequestError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderClient extends Context.Service<
|
||||||
|
ProviderClient,
|
||||||
|
ProviderClientService
|
||||||
|
>()("@punktfunk/plugin-kit/ProviderClient") {
|
||||||
|
static readonly layer: Layer.Layer<ProviderClient, never, HostClient> =
|
||||||
|
Layer.effect(ProviderClient)(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const host = yield* HostClient;
|
||||||
|
return {
|
||||||
|
reconcile: (providerId, entries) =>
|
||||||
|
host
|
||||||
|
.request("PUT", `/library/provider/${providerId}`, entries)
|
||||||
|
.pipe(Effect.asVoid),
|
||||||
|
remove: (providerId) =>
|
||||||
|
host
|
||||||
|
.request("DELETE", `/library/provider/${providerId}`)
|
||||||
|
.pipe(Effect.asVoid),
|
||||||
|
} satisfies ProviderClientService;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// The async-main boundary — the one place the two-effect-instances problem is handled.
|
||||||
|
//
|
||||||
|
// The packaged runner bundles its OWN copy of effect + the SDK; a plugin package's imports
|
||||||
|
// resolve to the plugin's node_modules. An Effect-shaped `main` would therefore hand the
|
||||||
|
// runner Effect values built by a different effect instance (Context.Tag identity is not
|
||||||
|
// shared across instances). The kit sidesteps this by construction: the plugin exports a
|
||||||
|
// plain async `main`, and EVERYTHING Effect happens inside a ManagedRuntime built from the
|
||||||
|
// plugin's own effect instance. Only the plain `pf` facade object crosses the boundary.
|
||||||
|
//
|
||||||
|
// Shutdown: the runner interrupts its supervision tree on SIGINT/SIGTERM, but it cannot
|
||||||
|
// cancel an in-flight promise — so the kit installs its own signal hooks, interrupts the
|
||||||
|
// plugin fiber (running scoped finalizers: UI deregistration, watcher close, cache flush)
|
||||||
|
// and bounds the whole teardown with `shutdownGraceMs` so `main` always resolves.
|
||||||
|
import {
|
||||||
|
definePlugin,
|
||||||
|
type PluginDef,
|
||||||
|
type Punktfunk,
|
||||||
|
connect,
|
||||||
|
} from "@punktfunk/host";
|
||||||
|
import {
|
||||||
|
Cause,
|
||||||
|
Effect,
|
||||||
|
Exit,
|
||||||
|
Fiber,
|
||||||
|
Layer,
|
||||||
|
ManagedRuntime,
|
||||||
|
Scope,
|
||||||
|
} from "effect";
|
||||||
|
import {
|
||||||
|
type HostClient,
|
||||||
|
hostClientFromFacade,
|
||||||
|
type PluginInfo,
|
||||||
|
pluginInfoLayer,
|
||||||
|
} from "./host-client.js";
|
||||||
|
import { loggingLayer } from "./logging.js";
|
||||||
|
|
||||||
|
export interface PluginKitDef<E, R> {
|
||||||
|
/** Plugin id (`[a-z][a-z0-9-]*`) — also the registry id and provider id. */
|
||||||
|
readonly name: string;
|
||||||
|
readonly version?: string;
|
||||||
|
/** The plugin's service graph, built over the kit base (HostClient | PluginInfo). */
|
||||||
|
readonly layer: Layer.Layer<R, E, HostClient | PluginInfo>;
|
||||||
|
/** The long-running program. Scoped: acquired resources release on interruption. */
|
||||||
|
readonly main: Effect.Effect<
|
||||||
|
void,
|
||||||
|
E,
|
||||||
|
R | HostClient | PluginInfo | Scope.Scope
|
||||||
|
>;
|
||||||
|
/** Upper bound on graceful teardown after a signal (default 5000 ms). */
|
||||||
|
readonly shutdownGraceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number): Promise<"timeout"> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => resolve("timeout"), ms);
|
||||||
|
(t as { unref?: () => void }).unref?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const runWithFacade = async <E, R>(
|
||||||
|
def: PluginKitDef<E, R>,
|
||||||
|
pf: Punktfunk,
|
||||||
|
): Promise<void> => {
|
||||||
|
const base = Layer.mergeAll(
|
||||||
|
hostClientFromFacade(pf),
|
||||||
|
pluginInfoLayer({ name: def.name, version: def.version }),
|
||||||
|
loggingLayer(def.name),
|
||||||
|
);
|
||||||
|
const rt = ManagedRuntime.make(Layer.provideMerge(def.layer, base));
|
||||||
|
const graceMs = def.shutdownGraceMs ?? 5000;
|
||||||
|
|
||||||
|
const fiber = rt.runFork(Effect.scoped(def.main));
|
||||||
|
|
||||||
|
// Fires graceMs after the first signal; never before a signal — so racing against it
|
||||||
|
// is a no-op in normal operation and a hard teardown bound once a stop is requested.
|
||||||
|
let fireGrace: (v: "timeout") => void = () => {};
|
||||||
|
const gracePromise = new Promise<"timeout">((resolve) => {
|
||||||
|
fireGrace = resolve;
|
||||||
|
});
|
||||||
|
let stopping = false;
|
||||||
|
const onSignal = () => {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
void sleep(graceMs).then(fireGrace);
|
||||||
|
rt.runFork(Fiber.interrupt(fiber));
|
||||||
|
};
|
||||||
|
process.on("SIGINT", onSignal);
|
||||||
|
process.on("SIGTERM", onSignal);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const joined = rt.runPromise(Effect.exit(Fiber.join(fiber)));
|
||||||
|
const exit = await Promise.race([joined, gracePromise]);
|
||||||
|
if (exit === "timeout") {
|
||||||
|
console.error(
|
||||||
|
`[${def.name}] shutdown exceeded ${graceMs}ms — abandoning teardown`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
const cause = exit.cause;
|
||||||
|
// Pure interruption (signal-driven) is a clean stop; real failures propagate so
|
||||||
|
// the runner records the crash and restarts with backoff.
|
||||||
|
if (Cause.hasFails(cause) || Cause.hasDies(cause)) {
|
||||||
|
throw new Error(Cause.pretty(cause));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.removeListener("SIGINT", onSignal);
|
||||||
|
process.removeListener("SIGTERM", onSignal);
|
||||||
|
await Promise.race([rt.dispose(), sleep(2000)]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the plugin's `PluginDef` (the default export the runner discovers) from an
|
||||||
|
* Effect-native definition. The returned def has a plain async `main`, so the runner's
|
||||||
|
* sanity checks and supervision treat it exactly like any hand-written plugin.
|
||||||
|
*/
|
||||||
|
export const definePluginKit = <E, R>(def: PluginKitDef<E, R>): PluginDef =>
|
||||||
|
definePlugin({
|
||||||
|
name: def.name,
|
||||||
|
main: (pf: Punktfunk) => runWithFacade(def, pf),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Dev/CLI entry: `connect()` a facade ourselves and run the same program. */
|
||||||
|
export const runPluginKitDirect = async <E, R>(
|
||||||
|
def: PluginKitDef<E, R>,
|
||||||
|
): Promise<void> => {
|
||||||
|
const pf = await connect();
|
||||||
|
try {
|
||||||
|
await runWithFacade(def, pf);
|
||||||
|
} finally {
|
||||||
|
pf.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SSE support. `effect/unstable/httpapi` has no event-stream media type (verified at
|
||||||
|
// beta.99), so the status feed is a raw HttpRouter route beside the HttpApi contract —
|
||||||
|
// same wire shape the first-generation plugins used (`event: <name>` frames + comment
|
||||||
|
// pings), which is already proven through the console's reverse proxy.
|
||||||
|
import { Effect, Layer, Schedule, Stream } from "effect";
|
||||||
|
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
export interface SseRouteOptions<A> {
|
||||||
|
/** SSE `event:` name (default "message"). */
|
||||||
|
readonly event?: string;
|
||||||
|
/** Serialize one item (default JSON.stringify). */
|
||||||
|
readonly encode?: (a: A) => string;
|
||||||
|
/**
|
||||||
|
* Keepalive comment cadence in seconds (0 disables). Default 5 — deliberately below
|
||||||
|
* Bun's 10 s default `idleTimeout`, which `servePluginUi`'s server does not raise:
|
||||||
|
* a slower ping never arrives, because the idle connection is closed first.
|
||||||
|
*/
|
||||||
|
readonly pingSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register `GET <path>` streaming `stream` as server-sent events. Each subscriber gets
|
||||||
|
* an independent subscription; disconnects tear the stream down via scope closure.
|
||||||
|
*/
|
||||||
|
export const sseRoute = <A>(
|
||||||
|
path: `/${string}`,
|
||||||
|
stream: Stream.Stream<A>,
|
||||||
|
opts?: SseRouteOptions<A>,
|
||||||
|
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
|
||||||
|
const event = opts?.event ?? "message";
|
||||||
|
const encode = opts?.encode ?? ((a: A) => JSON.stringify(a));
|
||||||
|
const pingSeconds = opts?.pingSeconds ?? 5;
|
||||||
|
|
||||||
|
const frames = stream.pipe(
|
||||||
|
Stream.map((a) => `event: ${event}\ndata: ${encode(a)}\n\n`),
|
||||||
|
);
|
||||||
|
const pings =
|
||||||
|
pingSeconds > 0
|
||||||
|
? Stream.fromSchedule(Schedule.spaced(`${pingSeconds} seconds`)).pipe(
|
||||||
|
Stream.map(() => `: ping\n\n`),
|
||||||
|
)
|
||||||
|
: Stream.empty;
|
||||||
|
|
||||||
|
const body = Stream.merge(frames, pings).pipe(
|
||||||
|
Stream.map((s) => encoder.encode(s)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return HttpRouter.add(
|
||||||
|
"GET",
|
||||||
|
path,
|
||||||
|
Effect.succeed(
|
||||||
|
HttpServerResponse.stream(body, {
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"x-accel-buffering": "no",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// The generic sync engine — the poll/watch/debounce/coalesce/fingerprint machinery that
|
||||||
|
// was ~duplicated between rom-manager and playnite, as one Effect service.
|
||||||
|
//
|
||||||
|
// Semantics are a faithful port of the original Engine guard:
|
||||||
|
// - single-flight: a sync while one runs records a pending trigger and returns
|
||||||
|
// AlreadyRunning; the running pass re-fires once ("coalesced") when it finishes
|
||||||
|
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged
|
||||||
|
// - interval poll + best-effort fs watchers (recursive where the OS supports it, top-dir
|
||||||
|
// fallback on Linux) with debounce; the poll is the real safety net on SMB/NFS
|
||||||
|
// - every transition publishes a SyncStatus (the UI's SSE feed)
|
||||||
|
// All loops live in a private scope that `reconfigure` closes and rebuilds, and the whole
|
||||||
|
// engine tears down with the Scope it was constructed in.
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import {
|
||||||
|
Cause,
|
||||||
|
type Duration,
|
||||||
|
Effect,
|
||||||
|
Exit,
|
||||||
|
PubSub,
|
||||||
|
Queue,
|
||||||
|
Ref,
|
||||||
|
Scope,
|
||||||
|
Stream,
|
||||||
|
} from "effect";
|
||||||
|
import { SyncError } from "./errors.js";
|
||||||
|
|
||||||
|
export type SyncReason =
|
||||||
|
| "startup"
|
||||||
|
| "poll"
|
||||||
|
| "fs-change"
|
||||||
|
| "config-change"
|
||||||
|
| "manual"
|
||||||
|
| "coalesced";
|
||||||
|
|
||||||
|
export interface LastSync {
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly count: number;
|
||||||
|
readonly at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncOutcome<Report> =
|
||||||
|
| { readonly _tag: "Applied"; readonly report: Report; readonly count: number }
|
||||||
|
| { readonly _tag: "Unchanged"; readonly report: Report }
|
||||||
|
| { readonly _tag: "AlreadyRunning" };
|
||||||
|
|
||||||
|
export interface SyncStatus<Report> {
|
||||||
|
readonly syncing: boolean;
|
||||||
|
readonly lastReport?: Report;
|
||||||
|
readonly lastSync?: LastSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncSettings {
|
||||||
|
readonly pollInterval: Duration.Duration;
|
||||||
|
readonly watch: boolean;
|
||||||
|
readonly debounce: Duration.Duration;
|
||||||
|
readonly watchDirs: ReadonlyArray<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncEngineOptions<
|
||||||
|
Report,
|
||||||
|
Entries extends ReadonlyArray<unknown>,
|
||||||
|
R,
|
||||||
|
> {
|
||||||
|
/** Produce the full desired state. Pure of host effects — `apply` does the write. */
|
||||||
|
readonly compute: (
|
||||||
|
reason: SyncReason,
|
||||||
|
) => Effect.Effect<
|
||||||
|
{ readonly entries: Entries; readonly report: Report },
|
||||||
|
unknown,
|
||||||
|
R
|
||||||
|
>;
|
||||||
|
/** Push the desired state to the host (usually `ProviderClient.reconcile`). */
|
||||||
|
readonly apply: (entries: Entries) => Effect.Effect<void, unknown, R>;
|
||||||
|
/** Override the content fingerprint (default: sha256 of the entries JSON). */
|
||||||
|
readonly fingerprint?: (entries: Entries) => string;
|
||||||
|
/** Durable fingerprint storage (usually the plugin's CacheStore). */
|
||||||
|
readonly lastSync: {
|
||||||
|
readonly get: Effect.Effect<LastSync | undefined, never, R>;
|
||||||
|
readonly set: (last: LastSync) => Effect.Effect<void, never, R>;
|
||||||
|
};
|
||||||
|
/** Re-read on every `reconfigure` — loops restart with fresh settings. */
|
||||||
|
readonly settings: Effect.Effect<SyncSettings, never, R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncEngine<Report> {
|
||||||
|
readonly sync: (
|
||||||
|
reason: SyncReason,
|
||||||
|
) => Effect.Effect<SyncOutcome<Report>, SyncError>;
|
||||||
|
readonly status: Effect.Effect<SyncStatus<Report>>;
|
||||||
|
/** Emits on every sync start/finish — the UI's SSE feed. */
|
||||||
|
readonly changes: Stream.Stream<SyncStatus<Report>>;
|
||||||
|
/** Initial sync + start poll/watch loops (scoped to the construction Scope). */
|
||||||
|
readonly start: Effect.Effect<void>;
|
||||||
|
/** Restart loops with fresh settings, then sync("config-change"). */
|
||||||
|
readonly reconfigure: Effect.Effect<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFingerprint = (entries: unknown): string =>
|
||||||
|
createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
||||||
|
|
||||||
|
/** Best-effort watcher set over `dirs`: recursive where supported, top-dir fallback. */
|
||||||
|
const openWatchers = (
|
||||||
|
dirs: ReadonlyArray<string>,
|
||||||
|
onEvent: () => void,
|
||||||
|
): fs.FSWatcher[] => {
|
||||||
|
const out: fs.FSWatcher[] = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
out.push(fs.watch(dir, { recursive: true }, onEvent));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
out.push(fs.watch(dir, onEvent));
|
||||||
|
} catch {
|
||||||
|
// unwatchable (poll covers it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeSyncEngine = <
|
||||||
|
Report,
|
||||||
|
Entries extends ReadonlyArray<unknown>,
|
||||||
|
R,
|
||||||
|
>(
|
||||||
|
opts: SyncEngineOptions<Report, Entries, R>,
|
||||||
|
): Effect.Effect<SyncEngine<Report>, never, R | Scope.Scope> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const ctx = yield* Effect.context<R>();
|
||||||
|
const run = <A, E>(eff: Effect.Effect<A, E, R>): Effect.Effect<A, E> =>
|
||||||
|
Effect.provide(eff, ctx);
|
||||||
|
const fingerprint = opts.fingerprint ?? defaultFingerprint;
|
||||||
|
|
||||||
|
const flags = yield* Ref.make({ syncing: false, pending: false });
|
||||||
|
const lastReport = yield* Ref.make<Report | undefined>(undefined);
|
||||||
|
const hub = yield* PubSub.unbounded<SyncStatus<Report>>();
|
||||||
|
|
||||||
|
const status: Effect.Effect<SyncStatus<Report>> = Effect.gen(function* () {
|
||||||
|
const f = yield* Ref.get(flags);
|
||||||
|
return {
|
||||||
|
syncing: f.syncing,
|
||||||
|
lastReport: yield* Ref.get(lastReport),
|
||||||
|
lastSync: yield* run(opts.lastSync.get),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const publish = status.pipe(
|
||||||
|
Effect.flatMap((s) => PubSub.publish(hub, s)),
|
||||||
|
Effect.asVoid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const doSync = (
|
||||||
|
reason: SyncReason,
|
||||||
|
): Effect.Effect<SyncOutcome<Report>, SyncError> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { entries, report } = yield* run(opts.compute(reason)).pipe(
|
||||||
|
Effect.mapError((cause) => new SyncError({ reason, cause })),
|
||||||
|
);
|
||||||
|
yield* Ref.set(lastReport, report);
|
||||||
|
const fp = fingerprint(entries);
|
||||||
|
const prev = yield* run(opts.lastSync.get);
|
||||||
|
if (prev?.fingerprint === fp) {
|
||||||
|
yield* Effect.log(
|
||||||
|
`sync (${reason}): no changes (${entries.length} entries)`,
|
||||||
|
);
|
||||||
|
return { _tag: "Unchanged", report } as const;
|
||||||
|
}
|
||||||
|
yield* run(opts.apply(entries)).pipe(
|
||||||
|
Effect.mapError((cause) => new SyncError({ reason, cause })),
|
||||||
|
);
|
||||||
|
yield* run(
|
||||||
|
opts.lastSync.set({
|
||||||
|
fingerprint: fp,
|
||||||
|
count: entries.length,
|
||||||
|
at: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
yield* Effect.log(
|
||||||
|
`sync (${reason}): reconciled ${entries.length} entries`,
|
||||||
|
);
|
||||||
|
return { _tag: "Applied", report, count: entries.length } as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Errors must never kill a loop — log and carry on (the original's catch).
|
||||||
|
const safeSync = (reason: SyncReason): Effect.Effect<void> =>
|
||||||
|
sync(reason).pipe(
|
||||||
|
Effect.catch((e: SyncError) =>
|
||||||
|
Effect.logWarning(`sync (${reason}) failed: ${e.cause}`),
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sync = (
|
||||||
|
reason: SyncReason,
|
||||||
|
): Effect.Effect<SyncOutcome<Report>, SyncError> =>
|
||||||
|
Ref.modify(flags, (f) =>
|
||||||
|
f.syncing
|
||||||
|
? ([false, { ...f, pending: true }] as const)
|
||||||
|
: ([true, { ...f, syncing: true }] as const),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((acquired) => {
|
||||||
|
if (!acquired) {
|
||||||
|
return Effect.succeed({ _tag: "AlreadyRunning" } as const);
|
||||||
|
}
|
||||||
|
return publish.pipe(
|
||||||
|
Effect.andThen(doSync(reason)),
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pending = yield* Ref.modify(
|
||||||
|
flags,
|
||||||
|
(f) =>
|
||||||
|
[f.pending, { syncing: false, pending: false }] as const,
|
||||||
|
);
|
||||||
|
yield* publish;
|
||||||
|
if (pending) {
|
||||||
|
yield* Effect.forkDetach(safeSync("coalesced"));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- loops
|
||||||
|
const loopScope = yield* Ref.make<Scope.Closeable | undefined>(undefined);
|
||||||
|
|
||||||
|
const stopLoops = Effect.gen(function* () {
|
||||||
|
const prev = yield* Ref.getAndSet(loopScope, undefined);
|
||||||
|
if (prev) yield* Scope.close(prev, Exit.void);
|
||||||
|
});
|
||||||
|
|
||||||
|
const startLoops: Effect.Effect<void> = Effect.gen(function* () {
|
||||||
|
yield* stopLoops;
|
||||||
|
const scope = yield* Scope.make();
|
||||||
|
yield* Ref.set(loopScope, scope);
|
||||||
|
const settings = yield* run(opts.settings);
|
||||||
|
|
||||||
|
const pollLoop = Effect.forever(
|
||||||
|
Effect.sleep(settings.pollInterval).pipe(
|
||||||
|
Effect.andThen(safeSync("poll")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
yield* Effect.forkIn(pollLoop, scope);
|
||||||
|
|
||||||
|
if (settings.watch && settings.watchDirs.length > 0) {
|
||||||
|
const watchStream = Stream.callback<void>((queue) =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
openWatchers(settings.watchDirs, () => {
|
||||||
|
Queue.offerUnsafe(queue, undefined);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(watchers) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
for (const w of watchers) {
|
||||||
|
try {
|
||||||
|
w.close();
|
||||||
|
} catch {
|
||||||
|
// already closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const watchLoop = watchStream.pipe(
|
||||||
|
Stream.debounce(settings.debounce),
|
||||||
|
Stream.runForEach(() => safeSync("fs-change")),
|
||||||
|
);
|
||||||
|
yield* Effect.forkIn(watchLoop, scope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loop teardown rides the construction Scope (plugin shutdown).
|
||||||
|
yield* Effect.addFinalizer(() => stopLoops);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sync,
|
||||||
|
status,
|
||||||
|
changes: Stream.fromPubSub(hub),
|
||||||
|
start: safeSync("startup").pipe(Effect.andThen(startLoops)),
|
||||||
|
reconfigure: startLoops.pipe(
|
||||||
|
Effect.andThen(safeSync("config-change")),
|
||||||
|
),
|
||||||
|
} satisfies SyncEngine<Report>;
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/* punktfunk plugin-UI theme — the console's violet identity, packaged so plugin UIs
|
||||||
|
* render indistinguishably from the console they're embedded in.
|
||||||
|
*
|
||||||
|
* Extracted from the console's web/src/styles.css + timing-functions.css (keep in sync —
|
||||||
|
* console/theme unification is a tracked follow-up). Consume as the FIRST import of the
|
||||||
|
* plugin UI's Tailwind entry, then add the app-specific lines yourself:
|
||||||
|
*
|
||||||
|
* @import "tailwindcss";
|
||||||
|
* @import "tw-animate-css";
|
||||||
|
* @import "@punktfunk/plugin-kit/theme.css";
|
||||||
|
* @custom-variant dark (&:is(.dark *));
|
||||||
|
* @source "../node_modules/@unom/ui/dist/**\/*.{js,mjs}";
|
||||||
|
*
|
||||||
|
* Pin `<html class="dark">` (the console does) — removing the class yields light.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Penner easing tokens — @unom/ui's accordion/collapsible/material resolve these. ── */
|
||||||
|
@theme {
|
||||||
|
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
|
||||||
|
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
|
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
|
||||||
|
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
||||||
|
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
|
||||||
|
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||||
|
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||||
|
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
|
||||||
|
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||||
|
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
|
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||||
|
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
|
||||||
|
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
|
||||||
|
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
||||||
|
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
|
||||||
|
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
|
||||||
|
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
|
||||||
|
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── punktfunk brand · violet product chrome ────────────────────────────────
|
||||||
|
Light (lavender) is the :root default; dark (violet-tinted app-icon chrome) is the
|
||||||
|
`.dark` override. The token set feeds BOTH @unom/ui's semantic contract
|
||||||
|
(--brand/--main/--neutral…) and the shadcn-style vocabulary (--background/--card/…),
|
||||||
|
mapped onto one palette. Indirection tokens live in :root only — CSS resolves var()
|
||||||
|
per-theme at use time, so .dark overrides just the raw values. */
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
|
||||||
|
/* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */
|
||||||
|
--pf-brand: #6c5bf3; /* deep violet — primary on light */
|
||||||
|
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
|
||||||
|
--pf-highlight: #d2c9fb; /* lens highlight */
|
||||||
|
|
||||||
|
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #1b1430;
|
||||||
|
--card: #f6f2ff;
|
||||||
|
--card-foreground: #1b1430;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #1b1430;
|
||||||
|
--muted: #f1ecfd;
|
||||||
|
--muted-foreground: #6f6a86;
|
||||||
|
--secondary: #ece6fb;
|
||||||
|
--secondary-foreground: #1b1430;
|
||||||
|
/* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour,
|
||||||
|
so we tint it toward the brand violet (the same in both themes). */
|
||||||
|
--accent: var(--pf-brand);
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--border: #e4dcf7;
|
||||||
|
--input: #e4dcf7;
|
||||||
|
--ring: var(--pf-brand);
|
||||||
|
|
||||||
|
/* Primary = the brand (buttons, active nav, default badges). */
|
||||||
|
--primary: var(--pf-brand);
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
|
--success: oklch(0.6 0.14 160);
|
||||||
|
--warn: oklch(0.84 0.16 84);
|
||||||
|
--destructive: oklch(0.55 0.22 18);
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* ── @unom/ui semantic token contract (its components read these names). ──
|
||||||
|
These are indirections — they follow the raw tokens above per-theme. */
|
||||||
|
--main: var(--foreground);
|
||||||
|
--brand: var(--pf-brand);
|
||||||
|
--brand-light: var(--pf-brand-light);
|
||||||
|
--highlight: var(--pf-highlight);
|
||||||
|
--neutral: var(--card); /* @unom card default surface (bg-neutral) */
|
||||||
|
--neutral-accent: var(
|
||||||
|
--secondary
|
||||||
|
); /* accent / nested surface (bg-neutral-accent) */
|
||||||
|
--neutral-highlight: var(--border);
|
||||||
|
--error: var(--destructive);
|
||||||
|
|
||||||
|
--font-display: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */
|
||||||
|
--radius-card-min: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark · the violet-tinted app-icon chrome. Overrides only the raw values —
|
||||||
|
the indirection tokens in :root resolve to these automatically. */
|
||||||
|
.dark {
|
||||||
|
--background: #141019;
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: #1c1530;
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: #1c1530;
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: #1f1830;
|
||||||
|
--muted-foreground: oklch(0.728 0.03 286);
|
||||||
|
--secondary: #241c3d;
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--border: #2a2148;
|
||||||
|
--input: #2a2148;
|
||||||
|
--ring: var(--pf-brand-light);
|
||||||
|
|
||||||
|
/* Lighter violet reads better against the dark surface. */
|
||||||
|
--primary: var(--pf-brand-light);
|
||||||
|
--primary-foreground: #141019;
|
||||||
|
|
||||||
|
--success: oklch(0.7 0.15 160);
|
||||||
|
--warn: oklch(0.87 0.15 84);
|
||||||
|
--destructive: oklch(0.62 0.21 18);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
|
||||||
|
and @unom/ui's, resolved to one set of values. */
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-button: 9999px;
|
||||||
|
--radius-card: calc(var(--radius) * 2);
|
||||||
|
--radius-main: calc(var(--radius) * 2);
|
||||||
|
|
||||||
|
--spacing-input-height: 3rem;
|
||||||
|
--spacing-padding-card: 1.25rem;
|
||||||
|
--spacing-card: 1.5rem;
|
||||||
|
--spacing-main: 15px;
|
||||||
|
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-display: var(--font-display);
|
||||||
|
|
||||||
|
/* shadcn-style colour tokens. */
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-warn: var(--warn);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
/* @unom/ui colour tokens. */
|
||||||
|
--color-main: var(--main);
|
||||||
|
--color-brand: var(--brand);
|
||||||
|
--color-brand-light: var(--brand-light);
|
||||||
|
--color-neutral: var(--neutral);
|
||||||
|
--color-neutral-accent: var(--neutral-accent);
|
||||||
|
--color-neutral-highlight: var(--neutral-highlight);
|
||||||
|
--color-highlight: var(--highlight);
|
||||||
|
--color-error: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion / collapsible keyframes @unom/ui's interactive surfaces animate to. */
|
||||||
|
@theme {
|
||||||
|
--animate-accordion-down: accordion-down 0.4s var(--ease-out-quart);
|
||||||
|
--animate-accordion-up: accordion-up 0.4s var(--ease-out-quart);
|
||||||
|
--animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart);
|
||||||
|
--animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes collapsible-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes collapsible-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings:
|
||||||
|
"rlig" 1,
|
||||||
|
"calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two vocabulary collisions, resolved ─────────────────────────────────────
|
||||||
|
Both come from @unom/ui's own palette meaning something different by the same
|
||||||
|
token name than this (console) palette does. Unlayered on purpose: unlayered
|
||||||
|
rules beat Tailwind's layered utilities of equal specificity.
|
||||||
|
|
||||||
|
1. `--secondary` is muted TEXT to @unom/ui (its theme sets a mid-grey, used for
|
||||||
|
EmptyState captions, badge text, table headers) but a dark SURFACE here
|
||||||
|
(`bg-secondary`). Tailwind derives both utilities from one token, so the
|
||||||
|
text lane is re-pointed or every caption renders dark-on-dark. */
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. @unom/ui's Card rings `ring-2 ring-accent`, sized for its subtle accent;
|
||||||
|
here `--accent` IS the brand violet, so an unsoftened ring shouts. The
|
||||||
|
console solves this in its own Card wrapper ("2px ring → subtle 1px brand
|
||||||
|
tint"); plugin UIs get the same result without wrapping anything. Scoped to
|
||||||
|
`rounded-card.ring-2` so only @unom/ui card surfaces are affected, and
|
||||||
|
explicit `ring-accent/NN` utilities keep their own value. */
|
||||||
|
.rounded-card.ring-2 {
|
||||||
|
--tw-ring-color: color-mix(in oklch, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// The Effect face of `servePluginUi`: build the plugin's local API from an HttpApi (or
|
||||||
|
// any HttpRouter route layers), mount it as the SDK server's `fetch` handler, and manage
|
||||||
|
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
|
||||||
|
// core-only env layers, no platform package, SPA fallthrough preserved.
|
||||||
|
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
|
||||||
|
import { Effect, FileSystem, Layer, Path, Scope } from "effect";
|
||||||
|
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
|
||||||
|
import { UiServeError } from "./errors.js";
|
||||||
|
import { HostClient, PluginInfo } from "./host-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything `HttpApiBuilder.layer` needs beyond the router, satisfied from effect core —
|
||||||
|
* plugins never pull a platform package for their UI API.
|
||||||
|
*/
|
||||||
|
export const httpApiEnv = Layer.provideMerge(
|
||||||
|
Layer.mergeAll(Etag.layerWeak, Path.layer, HttpPlatform.layer),
|
||||||
|
FileSystem.layerNoop({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ServeUiOptions {
|
||||||
|
/** Console nav title. */
|
||||||
|
readonly title: string;
|
||||||
|
/** lucide icon name for the console nav. */
|
||||||
|
readonly icon?: string;
|
||||||
|
/** Defaults to `PluginInfo.version`. */
|
||||||
|
readonly version?: string;
|
||||||
|
/** Built SPA directory (served with SPA fallback by the SDK). */
|
||||||
|
readonly staticDir?: string | URL;
|
||||||
|
/**
|
||||||
|
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
|
||||||
|
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
|
||||||
|
* here — only `HttpRouter` may remain open.
|
||||||
|
*/
|
||||||
|
readonly api: Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
||||||
|
/** Path prefix owned by the API handler (default "/api/"). */
|
||||||
|
readonly apiPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the plugin UI (scoped): the API handler and the loopback server come up together
|
||||||
|
* and the release path deregisters from the host, stops the server, and disposes the
|
||||||
|
* handler runtime — in that order.
|
||||||
|
*/
|
||||||
|
export const serveUi = (
|
||||||
|
opts: ServeUiOptions,
|
||||||
|
): Effect.Effect<
|
||||||
|
PluginUiHandle,
|
||||||
|
UiServeError,
|
||||||
|
HostClient | PluginInfo | Scope.Scope
|
||||||
|
> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const host = yield* HostClient;
|
||||||
|
const info = yield* PluginInfo;
|
||||||
|
const prefix = opts.apiPrefix ?? "/api/";
|
||||||
|
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
|
Layer.provide(opts.api, httpApiEnv),
|
||||||
|
);
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(() => dispose()).pipe(Effect.ignore),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetch = async (req: Request): Promise<Response | undefined> => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
|
||||||
|
return handler(req);
|
||||||
|
};
|
||||||
|
|
||||||
|
return yield* Effect.acquireRelease(
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
servePluginUi(host.facade, {
|
||||||
|
id: info.name,
|
||||||
|
title: opts.title,
|
||||||
|
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
|
||||||
|
...((opts.version ?? info.version) !== undefined
|
||||||
|
? { version: opts.version ?? info.version }
|
||||||
|
: {}),
|
||||||
|
...(opts.staticDir !== undefined
|
||||||
|
? { staticDir: opts.staticDir }
|
||||||
|
: {}),
|
||||||
|
fetch,
|
||||||
|
}),
|
||||||
|
catch: (cause) => new UiServeError({ cause }),
|
||||||
|
}),
|
||||||
|
(handle) => Effect.promise(() => handle.close()).pipe(Effect.ignore),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// The library-provider wire schemas — a browser-safe module (no node imports) so plugin
|
||||||
|
// CONTRACTS can share these types with their UIs. Mirrors the host's `ProviderEntryInput`
|
||||||
|
// (crates/punktfunk-host mgmt/library.rs). Identity codecs: plain JSON shapes, so values
|
||||||
|
// pass through unencoded; the value is the shared type + authoring validation.
|
||||||
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
export const Artwork = Schema.Struct({
|
||||||
|
portrait: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
hero: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
logo: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
header: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
});
|
||||||
|
export type Artwork = typeof Artwork.Type;
|
||||||
|
|
||||||
|
export const LaunchSpec = Schema.Struct({
|
||||||
|
kind: Schema.Literal("command"),
|
||||||
|
value: Schema.String,
|
||||||
|
});
|
||||||
|
export type LaunchSpec = typeof LaunchSpec.Type;
|
||||||
|
|
||||||
|
export const PrepStep = Schema.Struct({
|
||||||
|
do: Schema.String,
|
||||||
|
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
});
|
||||||
|
export type PrepStep = typeof PrepStep.Type;
|
||||||
|
|
||||||
|
export const ProviderEntry = Schema.Struct({
|
||||||
|
external_id: Schema.String,
|
||||||
|
title: Schema.String,
|
||||||
|
art: Schema.optionalKey(Artwork),
|
||||||
|
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
|
||||||
|
prep: Schema.optionalKey(Schema.Array(PrepStep)),
|
||||||
|
});
|
||||||
|
export type ProviderEntry = typeof ProviderEntry.Type;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// ConfigService semantics (the canonical suite — ported from rom-manager's state.test.ts
|
||||||
|
// obligations): raw round-trip, schema defaults at decode only, atomic writes, missing
|
||||||
|
// file == defaults, world-writable refusal, changes stream.
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { Effect, Fiber, Schema, Stream } from "effect";
|
||||||
|
import {
|
||||||
|
type ConfigService,
|
||||||
|
makeConfigService,
|
||||||
|
pluginInfoLayer,
|
||||||
|
pluginStateDir,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
const TestSchema = Schema.Struct({
|
||||||
|
roots: Schema.Array(Schema.String).pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed([]), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
sync: Schema.Struct({
|
||||||
|
pollMinutes: Schema.Number.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
watch: Schema.Boolean.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}).pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed({}), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PLUGIN = "kit-config-test";
|
||||||
|
let tmp: string;
|
||||||
|
|
||||||
|
const withService = <A>(
|
||||||
|
f: (svc: ConfigService<typeof TestSchema>) => Effect.Effect<A, unknown>,
|
||||||
|
): Promise<A> =>
|
||||||
|
Effect.runPromise(
|
||||||
|
makeConfigService({ schema: TestSchema }).pipe(
|
||||||
|
Effect.flatMap(f),
|
||||||
|
Effect.provide(pluginInfoLayer({ name: PLUGIN })),
|
||||||
|
) as Effect.Effect<A, never>,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-config-"));
|
||||||
|
process.env.PUNKTFUNK_CONFIG_DIR = tmp;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigService", () => {
|
||||||
|
test("missing file decodes to full defaults", async () => {
|
||||||
|
const loaded = await withService((svc) => svc.load);
|
||||||
|
expect(loaded).toEqual({
|
||||||
|
roots: [],
|
||||||
|
sync: { pollMinutes: 15, watch: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveRaw persists the RAW shape verbatim (no defaults baked in)", async () => {
|
||||||
|
await withService((svc) => svc.saveRaw({ roots: ["/roms"] }));
|
||||||
|
const onDisk = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(onDisk).toEqual({ roots: ["/roms"] }); // no sync block materialized
|
||||||
|
const loaded = await withService((svc) => svc.load);
|
||||||
|
expect(loaded.sync.pollMinutes).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveRaw rejects an invalid raw config and leaves the file untouched", async () => {
|
||||||
|
await withService((svc) => svc.saveRaw({ roots: ["/a"] }));
|
||||||
|
const before = fs.readFileSync(
|
||||||
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const exit = await withService((svc) =>
|
||||||
|
Effect.exit(svc.saveRaw({ roots: "not-an-array" })),
|
||||||
|
);
|
||||||
|
expect(exit._tag).toBe("Failure");
|
||||||
|
const after = fs.readFileSync(
|
||||||
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown keys in the file are tolerated and survive loadRaw", async () => {
|
||||||
|
fs.mkdirSync(pluginStateDir(PLUGIN), { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||||
|
JSON.stringify({ roots: [], ui: { port: 5885 }, devEntry: true }),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
const raw = (await withService((svc) => svc.loadRaw)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(raw.ui).toEqual({ port: 5885 }); // verbatim — a save decides what survives
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a group/world-writable config file (POSIX)", async () => {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
fs.mkdirSync(pluginStateDir(PLUGIN), { recursive: true, mode: 0o700 });
|
||||||
|
const file = path.join(pluginStateDir(PLUGIN), "config.json");
|
||||||
|
fs.writeFileSync(file, "{}");
|
||||||
|
fs.chmodSync(file, 0o666); // bypass umask — writeFileSync's mode is masked
|
||||||
|
const exit = await withService((svc) => Effect.exit(svc.load));
|
||||||
|
expect(exit._tag).toBe("Failure");
|
||||||
|
if (exit._tag === "Failure") {
|
||||||
|
expect(String(exit.cause)).toContain("ConfigPermissionError");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changes stream emits the decoded config after saveRaw", async () => {
|
||||||
|
const decoded = await withService((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fiber = yield* Effect.forkChild(
|
||||||
|
svc.changes.pipe(Stream.take(1), Stream.runCollect),
|
||||||
|
);
|
||||||
|
yield* Effect.sleep("20 millis"); // let the subscription attach
|
||||||
|
yield* svc.saveRaw({ roots: ["/x"], sync: { pollMinutes: 5 } });
|
||||||
|
return yield* Fiber.join(fiber);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(decoded).toEqual([
|
||||||
|
{ roots: ["/x"], sync: { pollMinutes: 5, watch: true } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Spike 2 (plan Phase 0): prove the browser-side client strategy for the console proxy
|
||||||
|
// prefix (`/plugin-ui/<id>/...`), headless via AtomRegistry (no React needed).
|
||||||
|
//
|
||||||
|
// The console serves the plugin SPA under a path prefix; the HttpApi contract uses
|
||||||
|
// absolute endpoint paths ("/api/status"). This spike pins down how the derived client
|
||||||
|
// must be configured so requests keep the prefix:
|
||||||
|
// - transformClient + HttpClientRequest.prependUrl(prefix) → expected to work
|
||||||
|
// - baseUrl with a path prefix → documented behavior
|
||||||
|
// Also verifies the nested `withDecodingDefaultKey` config-defaults pattern the kit's
|
||||||
|
// Config service relies on.
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Effect, Layer, Schema } from "effect";
|
||||||
|
import {
|
||||||
|
HttpClient,
|
||||||
|
HttpClientRequest,
|
||||||
|
HttpClientResponse,
|
||||||
|
} from "effect/unstable/http";
|
||||||
|
import {
|
||||||
|
HttpApi,
|
||||||
|
HttpApiEndpoint,
|
||||||
|
HttpApiGroup,
|
||||||
|
} from "effect/unstable/httpapi";
|
||||||
|
import { AtomHttpApi, AtomRegistry } from "effect/unstable/reactivity";
|
||||||
|
|
||||||
|
const Pong = Schema.Struct({ ok: Schema.Boolean });
|
||||||
|
|
||||||
|
const api = HttpApi.make("spike").add(
|
||||||
|
HttpApiGroup.make("spike").add(
|
||||||
|
HttpApiEndpoint.get("ping", "/api/ping", { success: Pong }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const captureClient = (captured: Array<string>) =>
|
||||||
|
HttpClient.make((request) => {
|
||||||
|
captured.push(request.url);
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(request, Response.json({ ok: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PREFIX = "http://plugin.local/plugin-ui/rom-manager";
|
||||||
|
|
||||||
|
describe("spike 2: client prefix through the console proxy", () => {
|
||||||
|
test("transformClient + prependUrl keeps the path prefix", async () => {
|
||||||
|
const captured: Array<string> = [];
|
||||||
|
class Api extends AtomHttpApi.Service<Api>()("SpikeApiPrepend", {
|
||||||
|
api,
|
||||||
|
httpClient: Layer.succeed(HttpClient.HttpClient)(
|
||||||
|
captureClient(captured),
|
||||||
|
),
|
||||||
|
transformClient: HttpClient.mapRequest(
|
||||||
|
HttpClientRequest.prependUrl(PREFIX),
|
||||||
|
),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const registry = AtomRegistry.make();
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
AtomRegistry.getResult(registry, Api.query("spike", "ping", {})),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(captured).toHaveLength(1);
|
||||||
|
expect(captured[0]).toBe(`${PREFIX}/api/ping`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("documents baseUrl behavior with a path-prefix base", async () => {
|
||||||
|
const captured: Array<string> = [];
|
||||||
|
class Api extends AtomHttpApi.Service<Api>()("SpikeApiBaseUrl", {
|
||||||
|
api,
|
||||||
|
httpClient: Layer.succeed(HttpClient.HttpClient)(
|
||||||
|
captureClient(captured),
|
||||||
|
),
|
||||||
|
baseUrl: PREFIX,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const registry = AtomRegistry.make();
|
||||||
|
await Effect.runPromise(
|
||||||
|
AtomRegistry.getResult(registry, Api.query("spike", "ping", {})),
|
||||||
|
);
|
||||||
|
expect(captured).toHaveLength(1);
|
||||||
|
// If this equals `${PREFIX}/api/ping`, baseUrl would also be fine; if the prefix is
|
||||||
|
// dropped (URL-resolution semantics for absolute paths), transformClient is the way.
|
||||||
|
// Either way the assertion records the actual behavior for the kit docs.
|
||||||
|
console.log("baseUrl produced:", captured[0]);
|
||||||
|
expect(captured[0]).toContain("/api/ping");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spike 2b: nested withDecodingDefaultKey config defaults", () => {
|
||||||
|
// withDecodingDefaultKey wraps the schema in optionalKey itself; the default is an
|
||||||
|
// Effect producing the ENCODED value. `encodingStrategy: "omit"` makes encode drop
|
||||||
|
// defaulted keys again — the raw-round-trip behavior the kit Config service wants.
|
||||||
|
const SyncCfg = Schema.Struct({
|
||||||
|
pollMinutes: Schema.Number.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
watch: Schema.Boolean.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const Cfg = Schema.Struct({
|
||||||
|
roots: Schema.Array(Schema.String).pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed([]), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
sync: SyncCfg.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed({}), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty raw file decodes to full defaults (nested)", () => {
|
||||||
|
const decoded = Schema.decodeUnknownSync(Cfg)({});
|
||||||
|
expect(decoded).toEqual({
|
||||||
|
roots: [],
|
||||||
|
sync: { pollMinutes: 15, watch: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partially-authored raw keeps authored values, fills the rest", () => {
|
||||||
|
const decoded = Schema.decodeUnknownSync(Cfg)({
|
||||||
|
roots: ["/roms"],
|
||||||
|
sync: { pollMinutes: 5 },
|
||||||
|
});
|
||||||
|
expect(decoded).toEqual({
|
||||||
|
roots: ["/roms"],
|
||||||
|
sync: { pollMinutes: 5, watch: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown keys in the raw file are tolerated (legacy ui/devEntry)", () => {
|
||||||
|
const decoded = Schema.decodeUnknownSync(Cfg)({
|
||||||
|
ui: { port: 5885 },
|
||||||
|
devEntry: true,
|
||||||
|
} as unknown);
|
||||||
|
expect(decoded.roots).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Spike 1 (plan Phase 0): prove that an `effect/unstable/httpapi` HttpApi can serve as the
|
||||||
|
// plugin-local API behind the SDK's `servePluginUi` on Bun, using ONLY effect-core layers
|
||||||
|
// (no @effect/platform-node / platform-bun) — the riskiest seam of the plugin-kit design.
|
||||||
|
//
|
||||||
|
// Validates:
|
||||||
|
// 1. HttpApi + HttpApiBuilder.group + HttpRouter.toWebHandler answer plain fetch Requests.
|
||||||
|
// 2. The handler slots into servePluginUi's `fetch` contract: /api/* handled, everything
|
||||||
|
// else falls through (returns undefined) to the static/404 path.
|
||||||
|
// 3. The real servePluginUi server (loopback, per-boot bearer secret, __health) proxies
|
||||||
|
// into the HttpApi handler end-to-end.
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Effect, Layer, Schema } from "effect";
|
||||||
|
import * as FileSystem from "effect/FileSystem";
|
||||||
|
import * as Path from "effect/Path";
|
||||||
|
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
|
||||||
|
import {
|
||||||
|
HttpApi,
|
||||||
|
HttpApiBuilder,
|
||||||
|
HttpApiEndpoint,
|
||||||
|
HttpApiGroup,
|
||||||
|
} from "effect/unstable/httpapi";
|
||||||
|
import { servePluginUi } from "@punktfunk/host";
|
||||||
|
import type { Punktfunk } from "@punktfunk/host";
|
||||||
|
|
||||||
|
const Pong = Schema.Struct({ ok: Schema.Boolean, source: Schema.String });
|
||||||
|
const EchoIn = Schema.Struct({ msg: Schema.String });
|
||||||
|
const EchoOut = Schema.Struct({ echoed: Schema.String });
|
||||||
|
|
||||||
|
const api = HttpApi.make("spike").add(
|
||||||
|
HttpApiGroup.make("spike")
|
||||||
|
.add(HttpApiEndpoint.get("ping", "/api/ping", { success: Pong }))
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("echo", "/api/echo", {
|
||||||
|
payload: EchoIn,
|
||||||
|
success: EchoOut,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupLive = HttpApiBuilder.group(api, "spike", (handlers) =>
|
||||||
|
handlers
|
||||||
|
.handle("ping", () => Effect.succeed({ ok: true, source: "httpapi" }))
|
||||||
|
.handle("echo", ({ payload }) => Effect.succeed({ echoed: payload.msg })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Core-only environment for HttpApiBuilder: no platform package needed. FileSystem is
|
||||||
|
// provideMerge'd so both HttpPlatform.layer and HttpApiBuilder see it satisfied.
|
||||||
|
const env = Layer.provideMerge(
|
||||||
|
Layer.mergeAll(Etag.layerWeak, Path.layer, HttpPlatform.layer),
|
||||||
|
FileSystem.layerNoop({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const appLayer = HttpApiBuilder.layer(api).pipe(
|
||||||
|
Layer.provide(groupLive),
|
||||||
|
Layer.provide(env),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("spike 1: HttpApi via toWebHandler on Bun", () => {
|
||||||
|
test("handles fetch-shaped requests directly", async () => {
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(appLayer);
|
||||||
|
try {
|
||||||
|
const ping = await handler(new Request("http://127.0.0.1/api/ping"));
|
||||||
|
expect(ping.status).toBe(200);
|
||||||
|
expect(await ping.json()).toEqual({ ok: true, source: "httpapi" });
|
||||||
|
|
||||||
|
const echo = await handler(
|
||||||
|
new Request("http://127.0.0.1/api/echo", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ msg: "hello" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(echo.status).toBe(200);
|
||||||
|
expect(await echo.json()).toEqual({ echoed: "hello" });
|
||||||
|
|
||||||
|
// Schema validation is live: bad payload is rejected, not 500.
|
||||||
|
const bad = await handler(
|
||||||
|
new Request("http://127.0.0.1/api/echo", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ nope: 1 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bad.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(bad.status).toBeLessThan(500);
|
||||||
|
} finally {
|
||||||
|
await dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("end-to-end behind servePluginUi (loopback + bearer secret)", async () => {
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(appLayer);
|
||||||
|
const registrations: Array<{ method: string; path: string; body: unknown }> =
|
||||||
|
[];
|
||||||
|
// servePluginUi only touches pf.request — a recording stub is a faithful host.
|
||||||
|
const pf = {
|
||||||
|
request: async (method: string, path: string, body?: unknown) => {
|
||||||
|
registrations.push({ method, path, body });
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
} as unknown as Punktfunk;
|
||||||
|
|
||||||
|
const kitFetch = async (req: Request): Promise<Response | undefined> => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (!url.pathname.startsWith("/api/")) return undefined; // static/404 fallthrough
|
||||||
|
return handler(req);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ui = await servePluginUi(pf, {
|
||||||
|
id: "spike",
|
||||||
|
title: "Spike",
|
||||||
|
fetch: kitFetch,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const reg = registrations.find(
|
||||||
|
(r) => r.method === "PUT" && r.path === "/plugins/spike",
|
||||||
|
);
|
||||||
|
expect(reg).toBeDefined();
|
||||||
|
const secret = (reg?.body as { ui: { secret: string } }).ui.secret;
|
||||||
|
expect(secret.length).toBeGreaterThanOrEqual(16);
|
||||||
|
const auth = { authorization: `Bearer ${secret}` };
|
||||||
|
|
||||||
|
// Health endpoint is served by servePluginUi itself.
|
||||||
|
const health = await fetch(
|
||||||
|
`http://127.0.0.1:${ui.port}/__health`,
|
||||||
|
{ headers: auth },
|
||||||
|
);
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
|
||||||
|
// HttpApi endpoint through the real server.
|
||||||
|
const ping = await fetch(`http://127.0.0.1:${ui.port}/api/ping`, {
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
expect(ping.status).toBe(200);
|
||||||
|
expect(await ping.json()).toEqual({ ok: true, source: "httpapi" });
|
||||||
|
|
||||||
|
// Wrong secret is rejected before reaching the handler.
|
||||||
|
const denied = await fetch(`http://127.0.0.1:${ui.port}/api/ping`, {
|
||||||
|
headers: { authorization: "Bearer nope-nope-nope-nope" },
|
||||||
|
});
|
||||||
|
expect(denied.status).toBe(401);
|
||||||
|
|
||||||
|
// Non-/api path falls through past our fetch (no staticDir here → 404).
|
||||||
|
const missing = await fetch(`http://127.0.0.1:${ui.port}/somewhere`, {
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
} finally {
|
||||||
|
await ui.close();
|
||||||
|
await dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Regression: the production shape of sseRoute — a long-lived PubSub-backed stream
|
||||||
|
// (the engine's status feed) plus the keepalive. The original suite only covered a
|
||||||
|
// finite, self-driving stream, which hid the fact that nothing ever reached the wire.
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Effect, Layer, PubSub, Stream } from "effect";
|
||||||
|
import { HttpRouter } from "effect/unstable/http";
|
||||||
|
import { httpApiEnv, sseRoute } from "../src/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read until the first bytes arrive or `ms` elapses. One sequential read at a time —
|
||||||
|
* re-entering read() while a previous read is pending is a spec violation and silently
|
||||||
|
* swallows data (which is exactly how this harness first lied about the ping path).
|
||||||
|
*/
|
||||||
|
const readSome = async (res: Response, ms: number): Promise<string> => {
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
if (!reader) return "";
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let out = "";
|
||||||
|
const timer = setTimeout(() => void reader.cancel().catch(() => {}), ms);
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) out += decoder.decode(value, { stream: true });
|
||||||
|
if (out.length > 0) break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cancelled by the deadline
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("sseRoute (live, PubSub-backed)", () => {
|
||||||
|
test("delivers frames published AFTER the request opened", async () => {
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const hub = yield* PubSub.unbounded<{ n: number }>();
|
||||||
|
const routes = sseRoute("/api/events", Stream.fromPubSub(hub), {
|
||||||
|
event: "status",
|
||||||
|
pingSeconds: 0,
|
||||||
|
});
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
|
Layer.provide(routes, httpApiEnv),
|
||||||
|
);
|
||||||
|
const res = yield* Effect.promise(() => handler(new Request("http://127.0.0.1/api/events")));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Publish only once the response is open — the real engine's pattern.
|
||||||
|
setTimeout(() => {
|
||||||
|
Effect.runFork(PubSub.publish(hub, { n: 1 }));
|
||||||
|
}, 50);
|
||||||
|
const body = yield* Effect.promise(() => readSome(res, 3000));
|
||||||
|
yield* Effect.promise(() => dispose());
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
const body = await Effect.runPromise(Effect.scoped(program));
|
||||||
|
expect(body).toContain('event: status\ndata: {"n":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits a keepalive on an otherwise silent stream", async () => {
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const hub = yield* PubSub.unbounded<{ n: number }>();
|
||||||
|
const routes = sseRoute("/api/events", Stream.fromPubSub(hub), {
|
||||||
|
event: "status",
|
||||||
|
pingSeconds: 1,
|
||||||
|
});
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
|
Layer.provide(routes, httpApiEnv),
|
||||||
|
);
|
||||||
|
const res = yield* Effect.promise(() => handler(new Request("http://127.0.0.1/api/events")));
|
||||||
|
const body = yield* Effect.promise(() => readSome(res, 4000));
|
||||||
|
yield* Effect.promise(() => dispose());
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
const body = await Effect.runPromise(Effect.scoped(program));
|
||||||
|
expect(body).toContain(": ping");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// sseRoute: frame format + integration with the toWebHandler pipeline.
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Layer, Schedule, Stream } from "effect";
|
||||||
|
import { HttpRouter } from "effect/unstable/http";
|
||||||
|
import { httpApiEnv, sseRoute } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("sseRoute", () => {
|
||||||
|
test("streams event frames in SSE wire format", async () => {
|
||||||
|
const stream = Stream.fromSchedule(Schedule.spaced("5 millis")).pipe(
|
||||||
|
Stream.map((n) => ({ tick: Number(n) })),
|
||||||
|
Stream.take(3),
|
||||||
|
);
|
||||||
|
const routes = sseRoute("/api/events", stream, {
|
||||||
|
event: "status",
|
||||||
|
pingSeconds: 0,
|
||||||
|
});
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
|
Layer.provide(routes, httpApiEnv),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const res = await handler(
|
||||||
|
new Request("http://127.0.0.1/api/events"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toContain(
|
||||||
|
"text/event-stream",
|
||||||
|
);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain('event: status\ndata: {"tick":0}\n\n');
|
||||||
|
expect(text).toContain('event: status\ndata: {"tick":2}\n\n');
|
||||||
|
} finally {
|
||||||
|
await dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// SyncEngine semantics: fingerprint skip, single-flight coalescing, status feed.
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Duration, Effect, Fiber, Ref, Scope, Stream } from "effect";
|
||||||
|
import {
|
||||||
|
type LastSync,
|
||||||
|
makeSyncEngine,
|
||||||
|
type SyncOutcome,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
interface Report {
|
||||||
|
readonly included: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const harness = (opts?: {
|
||||||
|
computeDelayMs?: number;
|
||||||
|
entries?: () => ReadonlyArray<string>;
|
||||||
|
}) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const applied = yield* Ref.make(0);
|
||||||
|
const last = yield* Ref.make<LastSync | undefined>(undefined);
|
||||||
|
const entries = opts?.entries ?? (() => ["a", "b"]);
|
||||||
|
const engine = yield* makeSyncEngine<Report, ReadonlyArray<string>, never>(
|
||||||
|
{
|
||||||
|
compute: () =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const e = entries();
|
||||||
|
return Effect.succeed({
|
||||||
|
entries: e,
|
||||||
|
report: { included: e.length },
|
||||||
|
});
|
||||||
|
}).pipe(
|
||||||
|
opts?.computeDelayMs
|
||||||
|
? Effect.delay(Duration.millis(opts.computeDelayMs))
|
||||||
|
: (x) => x,
|
||||||
|
),
|
||||||
|
apply: () => Ref.update(applied, (n) => n + 1),
|
||||||
|
lastSync: {
|
||||||
|
get: Ref.get(last),
|
||||||
|
set: (l) => Ref.set(last, l),
|
||||||
|
},
|
||||||
|
settings: Effect.succeed({
|
||||||
|
pollInterval: Duration.minutes(60),
|
||||||
|
watch: false,
|
||||||
|
debounce: Duration.millis(10),
|
||||||
|
watchDirs: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { engine, applied, last };
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = <A>(eff: Effect.Effect<A, unknown, Scope.Scope>): Promise<A> =>
|
||||||
|
Effect.runPromise(Effect.scoped(eff) as Effect.Effect<A>);
|
||||||
|
|
||||||
|
describe("SyncEngine", () => {
|
||||||
|
test("first sync applies; unchanged content skips the apply", async () => {
|
||||||
|
const { first, second, count } = await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const h = yield* harness();
|
||||||
|
const first = yield* h.engine.sync("manual");
|
||||||
|
const second = yield* h.engine.sync("manual");
|
||||||
|
return {
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
count: yield* Ref.get(h.applied),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(first._tag).toBe("Applied");
|
||||||
|
if (first._tag === "Applied") expect(first.count).toBe(2);
|
||||||
|
expect(second._tag).toBe("Unchanged");
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed content re-applies", async () => {
|
||||||
|
let call = 0;
|
||||||
|
const { outcomes, count } = await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const h = yield* harness({
|
||||||
|
entries: () => (call++ === 0 ? ["a"] : ["a", "b"]),
|
||||||
|
});
|
||||||
|
const o1 = yield* h.engine.sync("manual");
|
||||||
|
const o2 = yield* h.engine.sync("manual");
|
||||||
|
return { outcomes: [o1, o2], count: yield* Ref.get(h.applied) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(outcomes.map((o: SyncOutcome<Report>) => o._tag)).toEqual([
|
||||||
|
"Applied",
|
||||||
|
"Applied",
|
||||||
|
]);
|
||||||
|
expect(count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent trigger returns AlreadyRunning and coalesces into a follow-up", async () => {
|
||||||
|
const { during, count } = await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const h = yield* harness({ computeDelayMs: 50 });
|
||||||
|
const fiber = yield* Effect.forkChild(h.engine.sync("manual"));
|
||||||
|
yield* Effect.sleep("10 millis");
|
||||||
|
const during = yield* h.engine.sync("manual");
|
||||||
|
yield* Fiber.join(fiber);
|
||||||
|
// the coalesced re-run is forked detached — give it a beat
|
||||||
|
yield* Effect.sleep("120 millis");
|
||||||
|
return { during, count: yield* Ref.get(h.applied) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(during._tag).toBe("AlreadyRunning");
|
||||||
|
// first sync applied; the coalesced pass found identical content → skipped
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status feed publishes syncing transitions", async () => {
|
||||||
|
const statuses = await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const h = yield* harness();
|
||||||
|
const fiber = yield* Effect.forkChild(
|
||||||
|
h.engine.changes.pipe(Stream.take(2), Stream.runCollect),
|
||||||
|
);
|
||||||
|
yield* Effect.sleep("20 millis");
|
||||||
|
yield* h.engine.sync("manual");
|
||||||
|
return yield* Fiber.join(fiber);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(statuses[0]?.syncing).toBe(true);
|
||||||
|
expect(statuses[1]?.syncing).toBe(false);
|
||||||
|
expect(statuses[1]?.lastSync?.count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/host",
|
"name": "@punktfunk/host",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.2",
|
"@biomejs/biome": "^2.5.2",
|
||||||
"@inlang/paraglide-js": "^2.20.2",
|
"@inlang/paraglide-js": "^2.20.2",
|
||||||
|
"@inlang/plugin-message-format": "^4.4.0",
|
||||||
"@storybook/react-vite": "^10.4.6",
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||||
@@ -283,6 +284,8 @@
|
|||||||
|
|
||||||
"@inlang/paraglide-js": ["@inlang/paraglide-js@2.20.2", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^2.10.0", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "peerDependencies": { "typescript": ">=5.6" }, "optionalPeers": ["typescript"], "bin": { "paraglide-js": "bin/run.js" } }, "sha512-V8iY3uu/vQU94gEag1bdC3glMJSp4Dg3XMwfnabZLBh1Dv0F++DvDYlMeniqv2+nHbnS/twB75AM140OmpHDEg=="],
|
"@inlang/paraglide-js": ["@inlang/paraglide-js@2.20.2", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^2.10.0", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "peerDependencies": { "typescript": ">=5.6" }, "optionalPeers": ["typescript"], "bin": { "paraglide-js": "bin/run.js" } }, "sha512-V8iY3uu/vQU94gEag1bdC3glMJSp4Dg3XMwfnabZLBh1Dv0F++DvDYlMeniqv2+nHbnS/twB75AM140OmpHDEg=="],
|
||||||
|
|
||||||
|
"@inlang/plugin-message-format": ["@inlang/plugin-message-format@4.4.0", "", { "dependencies": { "flat": "^6.0.1" } }, "sha512-n4aXt6XVg5kxhKoLAhi9nMgZtCA9iS0QOaXte56VqxWHcfj9O4c4gOkyVQZH7H9D8h7OZufCrO1sZGYOypPwEA=="],
|
||||||
|
|
||||||
"@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="],
|
"@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="],
|
||||||
|
|
||||||
"@inlang/sdk": ["@inlang/sdk@2.10.2", "", { "dependencies": { "@lix-js/sdk": "0.4.10", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^14.0.0" } }, "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw=="],
|
"@inlang/sdk": ["@inlang/sdk@2.10.2", "", { "dependencies": { "@lix-js/sdk": "0.4.10", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^14.0.0" } }, "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw=="],
|
||||||
@@ -1383,6 +1386,8 @@
|
|||||||
|
|
||||||
"find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="],
|
"find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="],
|
||||||
|
|
||||||
|
"flat": ["flat@6.0.1", "", { "bin": { "flat": "cli.js" } }, "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw=="],
|
||||||
|
|
||||||
"focus-trap": ["focus-trap@7.5.4", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w=="],
|
"focus-trap": ["focus-trap@7.5.4", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w=="],
|
||||||
|
|
||||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,7 @@
|
|||||||
"description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
|
"description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "bun run codegen",
|
"prepare": "bun run codegen",
|
||||||
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide",
|
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
|
||||||
"predev": "orval --config orval.config.ts",
|
"predev": "orval --config orval.config.ts",
|
||||||
"dev": "vite dev --port 47992",
|
"dev": "vite dev --port 47992",
|
||||||
"prebuild": "orval --config orval.config.ts",
|
"prebuild": "orval --config orval.config.ts",
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.2",
|
"@biomejs/biome": "^2.5.2",
|
||||||
"@inlang/paraglide-js": "^2.20.2",
|
"@inlang/paraglide-js": "^2.20.2",
|
||||||
|
"@inlang/plugin-message-format": "^4.4.0",
|
||||||
"@storybook/react-vite": "^10.4.6",
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
"$schema": "https://inlang.com/schema/project-settings",
|
"$schema": "https://inlang.com/schema/project-settings",
|
||||||
"baseLocale": "en",
|
"baseLocale": "en",
|
||||||
"locales": ["en", "de"],
|
"locales": ["en", "de"],
|
||||||
"modules": [
|
"modules": ["./node_modules/@inlang/plugin-message-format/dist/index.js"],
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"
|
|
||||||
],
|
|
||||||
"plugin.inlang.messageFormat": {
|
"plugin.inlang.messageFormat": {
|
||||||
"pathPattern": "./messages/{locale}.json"
|
"pathPattern": "./messages/{locale}.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const DashboardView: FC<{
|
|||||||
on={s.audio_streaming}
|
on={s.audio_streaming}
|
||||||
/>
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{m.status_paired_count()}
|
{m.status_paired_count()}
|
||||||
</span>
|
</span>
|
||||||
@@ -50,7 +50,7 @@ export const DashboardView: FC<{
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{m.status_pin_pending()}
|
{m.status_pin_pending()}
|
||||||
</span>
|
</span>
|
||||||
@@ -134,7 +134,7 @@ const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
|
|||||||
on,
|
on,
|
||||||
}) => (
|
}) => (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Guard: assert paraglide actually compiled our translations.
|
||||||
|
//
|
||||||
|
// The inlang SDK treats a failed plugin import as a WARNING, not an error: if the
|
||||||
|
// message-format plugin can't be loaded, `paraglide-js compile` still prints
|
||||||
|
// "Successfully compiled" and exits 0 — having emitted an empty message set. vite
|
||||||
|
// then happily bundles that, and the console only dies at SSR time, deep inside
|
||||||
|
// renderToReadableStream, because every `m.foo()` is undefined. That shipped once
|
||||||
|
// (a Nix build, where the plugin was fetched from a CDN in a network-off sandbox),
|
||||||
|
// so the build now fails loudly instead.
|
||||||
|
//
|
||||||
|
// node tools/check-i18n.mjs # run from web/, after `paraglide-js compile`
|
||||||
|
//
|
||||||
|
// Env knobs: PARAGLIDE_OUTDIR (default ./src/paraglide), INLANG_PROJECT (default
|
||||||
|
// ./project.inlang).
|
||||||
|
|
||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
const outdir = resolve(process.env.PARAGLIDE_OUTDIR ?? "./src/paraglide");
|
||||||
|
const project = resolve(process.env.INLANG_PROJECT ?? "./project.inlang");
|
||||||
|
|
||||||
|
const fail = (msg) => {
|
||||||
|
console.error(`ERROR: i18n check failed — ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsPath = join(project, "settings.json");
|
||||||
|
if (!existsSync(settingsPath)) fail(`no inlang settings at ${settingsPath}`);
|
||||||
|
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||||
|
|
||||||
|
// Every plugin must resolve offline. A remote module makes the build depend on a CDN
|
||||||
|
// at compile time, which silently yields zero messages in any sandbox without network
|
||||||
|
// (Nix, air-gapped CI) — see the header.
|
||||||
|
for (const mod of settings.modules ?? []) {
|
||||||
|
if (/^https?:/i.test(mod)) {
|
||||||
|
fail(
|
||||||
|
`inlang module "${mod}" is a remote URL — vendor it as a devDependency and reference it by path, ` +
|
||||||
|
`else offline builds compile zero messages`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const modPath = resolve(project, "..", mod);
|
||||||
|
if (!existsSync(modPath))
|
||||||
|
fail(
|
||||||
|
`inlang module "${mod}" does not exist at ${modPath} (run \`bun install\`?)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The base-locale source file is the source of truth for how many messages we expect.
|
||||||
|
const pathPattern = settings["plugin.inlang.messageFormat"]?.pathPattern;
|
||||||
|
if (!pathPattern)
|
||||||
|
fail("settings.json has no plugin.inlang.messageFormat.pathPattern");
|
||||||
|
const basePath = resolve(
|
||||||
|
project,
|
||||||
|
"..",
|
||||||
|
pathPattern.replace("{locale}", settings.baseLocale),
|
||||||
|
);
|
||||||
|
if (!existsSync(basePath)) fail(`base-locale messages missing at ${basePath}`);
|
||||||
|
const expected = Object.keys(JSON.parse(readFileSync(basePath, "utf8"))).filter(
|
||||||
|
(k) => !k.startsWith("$"),
|
||||||
|
).length;
|
||||||
|
if (expected === 0) fail(`base-locale messages at ${basePath} are empty`);
|
||||||
|
|
||||||
|
const messagesDir = join(outdir, "messages");
|
||||||
|
if (!existsSync(messagesDir))
|
||||||
|
fail(`paraglide emitted no messages dir at ${messagesDir}`);
|
||||||
|
// paraglide writes one module per message plus an `_index.js` barrel.
|
||||||
|
const emitted = readdirSync(messagesDir).filter(
|
||||||
|
(f) => f.endsWith(".js") && f !== "_index.js",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (emitted < expected) {
|
||||||
|
fail(
|
||||||
|
`paraglide emitted ${emitted} messages but ${basePath} defines ${expected}. ` +
|
||||||
|
`The message-format plugin most likely failed to import — check the compile output for PluginImportError.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✔ i18n check: ${emitted} compiled messages for ${settings.locales.join(", ")}`,
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user