Compare commits

...

30 Commits

Author SHA1 Message Date
enricobuehler 84e1e5aeb4 fix(web): restore the Dashboard status tiles' top padding
apple / swift (push) Successful in 1m30s
apple / screenshots (push) Successful in 6m28s
windows-host / package (push) Successful in 9m27s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m11s
android / android (push) Successful in 13m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
decky / build-publish (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
ci / bench (push) Successful in 6m9s
arch / build-publish (push) Successful in 15m32s
deb / build-publish (push) Successful in 9m10s
deb / build-publish-host (push) Successful in 10m45s
ci / rust (push) Successful in 24m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m24s
docker / deploy-docs (push) Successful in 12s
CardContent is shadcn's header-adjacent variant (`p-4 pt-0 sm:p-6 sm:pt-0`),
but the four Live-status tiles use it with no CardHeader. tailwind-merge lets
the call site's unprefixed `p-4` cancel the base `pt-0`, yet nothing cancels
`sm:pt-0` — so the tiles lost their top padding at >=640px only, and the
content sat against the top edge. Restores the top inset at the sm breakpoint
and lets the content fill the stretched grid cell so it stays centred when a
sibling tile is taller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:13:34 +02:00
enricobuehler 1262fec2ae perf(zerocopy/vulkan): LN4 — global-priority VkBridge queue for the LINEAR/gamescope CSC
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 6m27s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
ci / bench (push) Successful in 7m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 8m47s
arch / build-publish (push) Successful in 15m47s
android / android (push) Successful in 16m2s
deb / build-publish-host (push) Successful in 12m13s
ci / rust (push) Successful in 26m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m3s
docker / deploy-docs (push) Successful in 16s
The RGB→NV12 compute CSC shares the SMs with the game; request an
elevated global-priority queue (VK_KHR/EXT_global_priority) so the
driver can schedule it ahead. PUNKTFUNK_VK_QUEUE_PRIORITY =
off|high|realtime (default realtime), downgrading REALTIME→HIGH→none
on NOT_PERMITTED/INITIALIZATION_FAILED — a refused class never fails
the bridge. Mirrors PyroWave's ac0e7332 (same honest caveat: WDDM
didn't move; the Linux driver may — contended-tail lever, correct and
harmless either way).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:12:42 +02:00
enricobuehler ae67315804 perf(encode/nvenc-linux): LN3 — pipelined-retrieve escalation replaces the depth-1 async foot-gun
apple / swift (push) Successful in 1m24s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 10m11s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m40s
ci / bench (push) Successful in 5m13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 17m27s
deb / build-publish (push) Successful in 12m39s
deb / build-publish-host (push) Successful in 9m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m27s
ci / rust (push) Successful in 27m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m1s
docker / deploy-docs (push) Successful in 17s
PUNKTFUNK_NVENC_ASYNC gains a real tri-state: 1 = always (as before, now
documented as ~+1 tick at depth-1), 0 = never (vetoes escalation), unset =
ADAPTIVE — off until the session loop's cadence-overrun detector escalates.

The host loop's adaptive-depth leaky bucket grows a second stage: once the
capturer's depth is maxed (Linux portal is permanently depth-1), it asks
the encoder for pipelined retrieve via the new Encoder::set_pipelined hook
(asked exactly once; default impl declines, Windows untouched).

nvenc_cuda engages at a safe point via a clean session rebuild WITHOUT the
IO-stream binding: with input==output stream bound, later stream work
waits on prior encode completions and would serialize a pipelined session
— stream-ordered submit and two-thread retrieve are mutually exclusive.
The ordered gate now also requires async_rt absence (belt-and-braces for
the runtime switch). Re-open's first frame is the standard session IDR.

On-hardware test: escalate mid-session → retrieve thread live, binding
gone, all AUs deliver, first post-escalation AU is the IDR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:10:24 +02:00
enricobuehler 256244430b feat(encode): Vulkan Video B1 — RGB-direct encode source via VCN EFC (PUNKTFUNK_VULKAN_RGB_DIRECT=1)
apple / swift (push) Successful in 1m25s
windows-host / package (push) Successful in 11m6s
apple / screenshots (push) Successful in 6m55s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m4s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / rust (push) Failing after 10m50s
arch / build-publish (push) Successful in 14m36s
ci / bench (push) Successful in 6m3s
android / android (push) Successful in 15m19s
deb / build-publish-host (push) Successful in 9m39s
deb / build-publish (push) Successful in 10m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m53s
docker / deploy-docs (push) Successful in 18s
design/vulkan-rgb-direct-encode.md B1: when the probe passes AND
PUNKTFUNK_VULKAN_RGB_DIRECT=1 (default OFF until the B2 on-glass A/B),
the session opens with pictureFormat=B8G8R8A8 and the captured RGB frame
becomes the encode source — the VCN EFC front-end does the 709-narrow CSC
inline during encode. Deleted from the per-frame path: the compute CSC
dispatch, both NV12 plane copies, the semaphore hop and one queue submit
(dmabuf frames are ONE encode-queue submit; ~17 MB/frame of GPU traffic
gone). The DPB stays NV12; RFI/RC/quality-level machinery is untouched.

Shape:
- probe_rgb_direct now returns the chroma-siting bits to create the
  session with (midpoint preferred, else cosited-even per axis); the open
  verdict line gains "active" / "available(off; ...)" states.
- RgbProfileStack: the rgb-chained video profile rebuilt on the stack per
  profiled-image creation after open (profile identity is by value) —
  dmabuf imports become profiled VIDEO_ENCODE_SRC images (import cache
  unchanged), the CPU staging image likewise (concurrent encode+compute).
- record_submit_rgb: steps 2–4 twin (dmabuf: single submit; CPU: staging
  copy on the compute queue, semaphore-ordered); shared step-1/bookkeeping.
- begin_encode_cmd takes a SrcAcquire (CSC general / fresh-import FOREIGN
  QFOT / cached visibility-only / staging TRANSFER_DST) so both paths
  share the encode recording.
- RGB-direct frames skip the CSC per-slot resources entirely (make_frame
  split into csc/common halves); cursor bitmaps warn once (EFC cannot
  composite; gamescope — the flagship — embeds the cursor itself).

On-glass (780M RADV PHOENIX, host Mesa 26.0.4): all four smokes pass
(vulkan_smoke{,_av1,_rgb,_rgb_av1}); the EFC-encoded H265 stream decodes
clean and matches the CSC-encoded stream at 49.9 dB average PSNR (min
48.9) — within the design's ±1-code-value tolerance. (Raw-OBU ffmpeg
probing of the tiny AV1 dumps fails identically for BOTH paths —
pre-existing dump quirk, not a stream defect.) check+clippy+full unit
suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:05:56 +02:00
enricobuehler 589d48a975 fix(plugin-kit): SSE keepalive must outpace Bun's idle timeout (0.1.4)
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
arch / build-publish (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
servePluginUi's Bun.serve leaves idleTimeout at Bun's 10s default, so the old
15s keepalive could never arrive — an idle status feed was closed before its
first ping, which is exactly what a live host showed. Default is now 5s.

Also adds the regression suite that should have caught it: the original test
only covered a finite, self-driving stream. The new one exercises the
production shape (PubSub-backed, published after the response opens) plus the
keepalive — with a reader that does not re-enter read(), which is what made the
first version of this test lie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:59:04 +02:00
enricobuehler 424bab1c3a fix(plugin-kit): soften @unom/ui's card ring for the brand palette (0.1.3)
apple / swift (push) Successful in 1m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
arch / build-publish (push) Has been cancelled
apple / screenshots (push) Has been cancelled
android / android (push) Successful in 14m52s
@unom/ui rings cards ring-2 ring-accent, sized for its own subtle accent; in
this palette --accent is the brand violet, so every card shouted. The console
already softens the same ring in its Card wrapper — plugin UIs now inherit that
instead of each wrapping AnimatedCard themselves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:47:51 +02:00
enricobuehler 5a27c02738 feat(encode/nvenc): LN1 phase-0 — slice-count + sub-frame-readback knobs and the slice-timing probe
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m55s
windows-host / package (push) Successful in 11m18s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 56s
ci / bench (push) Successful in 5m19s
android / android (push) Successful in 12m34s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
arch / build-publish (push) Successful in 15m25s
deb / build-publish (push) Successful in 11m48s
deb / build-publish-host (push) Successful in 9m33s
ci / rust (push) Successful in 24m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m36s
docker / deploy-docs (push) Successful in 10s
PUNKTFUNK_NVENC_SLICES=N (2..=32, default off) splits H.264/HEVC frames
into N slices (sliceMode 3); PUNKTFUNK_NVENC_SUBFRAME=1 (default off)
arms enableSubFrameWrite + reportSliceOffsets on sync sessions only.
Both experimental groundwork for sub-frame slice output (plan §7 LN1).

nvenc_cuda_subframe_slice_probe (on-hardware, ignored) answers the LN1
go/no-go: spins lock_bitstream(doNotWait) against an in-flight frame and
prints the (t_us, status, numSlices, bytes) timeline — incremental slice
availability and its spacing, or all-at-completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:44:54 +02:00
enricobuehler d5a0f5012b fix(encode): RGB-direct probe — accept cosited-even chroma siting (what VCN EFC actually offers)
apple / swift (push) Successful in 1m17s
windows-host / package (push) Successful in 9m23s
apple / screenshots (push) Successful in 6m43s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 6m28s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
deb / build-publish (push) Successful in 9m33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
android / android (push) Successful in 17m10s
arch / build-publish (push) Successful in 17m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m30s
deb / build-publish-host (push) Successful in 12m39s
ci / rust (push) Successful in 26m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m26s
docker / deploy-docs (push) Successful in 30s
On-glass B0 result from the 780M (RADV PHOENIX, host Mesa 26.0.4): the
probe returned no-709-narrow-midpoint because RADV advertises
xChromaOffsets = COSITED_EVEN only — the VCN EFC does canonical H.26x
left-cosited x-chroma, while the requirement was written to bit-match our
2x2-average (midpoint) shader. The delta is a half-pel chroma-x phase,
imperceptible and unsignalled in our bitstream; EFC's siting is arguably
the more correct one. Accept either siting bit per axis (model + range
stay exact: 709 narrow); B1 will pass the preferred available bit.

With this the 780M probes rgb_direct=available; both GPU smoke tests
(H265 + AV1) pass on that box against host Mesa 26.0.4 — the first
real-RADV validation of the poll fix, the quality-level control, and B0.
(Container mesa alone can't run the H265 smoke: Fedora ships RADV with
H264/H265 encode compiled out — AV1 only. Use the host ICD via
VK_ICD_FILENAMES on Atomic boxes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:44:33 +02:00
enricobuehler d833cff470 fix(plugin-kit): resolve the --secondary text/surface collision in theme.css (0.1.2)
apple / swift (push) Successful in 1m19s
apple / screenshots (push) Successful in 6m34s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 5m24s
deb / build-publish (push) Successful in 9m25s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish-host (push) Successful in 9m13s
android / android (push) Successful in 18m26s
arch / build-publish (push) Successful in 18m56s
ci / rust (push) Successful in 19m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m43s
docker / deploy-docs (push) Successful in 23s
@unom/ui reads --secondary as muted TEXT; this palette (like the console's) uses
it as a dark SURFACE, so EmptyState captions, badge text and table headers
rendered dark-on-dark. Tailwind derives both utilities from one token, so the
text lane is re-pointed at --muted-foreground here — once, for every plugin,
instead of in each plugin's stylesheet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:44:01 +02:00
enricobuehler 34ec57cb52 test(encode/nvenc-linux): on-hardware assertion that stream-ordered submit arms
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 6m19s
windows-host / package (push) Successful in 11m33s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 12m27s
ci / bench (push) Successful in 5m21s
decky / build-publish (push) Successful in 1m56s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
arch / build-publish (push) Successful in 16m24s
deb / build-publish (push) Successful in 9m21s
deb / build-publish-host (push) Successful in 9m24s
ci / rust (push) Successful in 19m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m26s
docker / deploy-docs (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:38:22 +02:00
enricobuehler cd36c46388 perf(encode/nvenc-linux): LN2 v1 — stream-ordered submit via NvEncSetIOCudaStreams
Bind the session's IO streams to the encode thread's high-priority copy
stream: in sync-retrieve depth-1 use the per-frame input copy and cursor
blend now enqueue with NO cuStreamSynchronize and encode_picture orders
after them on the stream. Same stream both directions, so the encode's
completion is inserted into the stream and later work (the next frame's
copy into a reused ring slot) waits for it.

Soundness gate: the fast path engages only when pending is empty (true
depth-1 usage) — every prior encode was drained by a blocking poll, and
the caller holds the frame payload across the matching poll (contract now
documented on Encoder::submit; both host loops already comply). Pipelined
callers and PUNKTFUNK_NVENC_ASYNC mode keep the blocking copies.

True zero-copy input registration (registering the worker-owned IPC
buffer directly) stays the LN2 v2 follow-up — it needs a contiguous
worker-pool NV12 layout and a registration<->IPC-mapping lifetime tie.

PUNKTFUNK_NVENC_STREAM_ORDERED=0 restores the old blocking behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:38:22 +02:00
enricobuehler 59b6d3796b perf(encode/nvenc-linux): LN0 — sampled submit/retrieve-lock split, sub-frame caps, zeroReorderDelay
Latency plan §7 LN0 (Linux/NVIDIA encode follow-on):
- sampled PUNKTFUNK_PERF submit split (copy/blend/map/pic) in nvenc_cuda —
  the host loop's submit_us folds all four together; the D2D input copy is
  the LN2 zero-copy target and now measurable on its own
- sampled blocking lock_bitstream timing on pf-nvenc-out — in two-thread
  mode the host loop's wait_us wraps a non-blocking poll, so the real
  encode wait was measured by no timer
- caps probe + log SUPPORT_SUBFRAME_READBACK / SUPPORT_DYNAMIC_SLICE_MODE
  (LN1 sub-frame slice-output prerequisites, fleet visibility)
- explicit zeroReorderDelay=1 in the shared low-latency config (P-only +
  no lookahead has no reordering anyway; pins the bit against preset or
  driver drift; shared with the Windows backend)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:38:22 +02:00
enricobuehler 78dba293a8 feat(encode): Vulkan Video B0 — vendored VK_VALVE_video_encode_rgb_conversion + RGB-direct probe telemetry
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m16s
windows-host / package (push) Successful in 10m2s
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m18s
android / android (push) Successful in 13m46s
arch / build-publish (push) Successful in 12m58s
ci / bench (push) Successful in 7m23s
decky / build-publish (push) Successful in 38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish (push) Successful in 9m35s
deb / build-publish-host (push) Successful in 9m43s
ci / rust (push) Successful in 28m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m42s
docker / deploy-docs (push) Successful in 25s
First phase of design/vulkan-rgb-direct-encode.md (punktfunk-planning): make
the captured BGRx dmabuf the direct encode source with the VCN EFC front-end
doing the 709-narrow CSC — deleting the per-frame compute CSC, both plane
copies, the semaphore hop and one queue submit.

B0 changes nothing about the encode path. It vendors the extension surface
(vk_valve_rgb.rs — ash 0.38 predates it; same rationale and style as the
vk_av1_encode module) and probes at open whether this host qualifies:
extension present (Mesa >= 26.0 + EFC hardware) → feature bit → conversion
caps cover the compute shader's exact math (709 / narrow / midpoint both
axes) → encode-src format set offers B8G8R8A8 with DRM-modifier tiling.
The verdict is one INFO line (rgb_direct=available | first missing
requirement) — the field telemetry that decides where B1 can default on.

Verified: cargo check + clippy clean + unit tests green (Linux box); the
probe short-circuits to no-ext on non-RADV drivers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:29:22 +02:00
enricobuehler cfdec2729d perf(encode): Vulkan Video — explicit VCN preset, persistent bitstream map, CSC split timing
apple / swift (push) Successful in 1m19s
apple / screenshots (push) Successful in 6m25s
windows-host / package (push) Successful in 9m18s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 51s
ci / bench (push) Successful in 6m15s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
deb / build-publish (push) Successful in 12m42s
deb / build-publish-host (push) Successful in 13m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m53s
ci / rust (push) Successful in 28m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m1s
docker / deploy-docs (push) Successful in 28s
Field follow-up to 6dc195f9 (the AMD host whose H265 encode sits at ~5 ms):
the remaining time is the VCN ASIC preset, and we never chose one.

1. Explicit encode quality level. RADV only emits a VCN preset op when the
   app issues ENCODE_QUALITY_LEVEL — we never did, so every session ran the
   firmware's default preset. Install the resolved level on the first
   frame's RESET control (chained ahead of the CBR state, which satisfies
   the spec's quality-change-carries-rate-control rule) and bake the same
   level into the session-parameters object (the spec requires the match).
   Default 0 = the fastest tier the driver exposes (RADV: SPEED, except
   H265 on pre-RDNA4 which the driver pins to BALANCE); clamped to the
   profile's maxQualityLevels. PUNKTFUNK_VULKAN_QUALITY=0..3 overrides for
   quality-biased setups. Logged at open so field logs show the tier.

2. Persistent bitstream mapping. read_slot vkMapMemory/vkUnmapMemory'd the
   host-visible bitstream buffer every frame; map each ring slot once at
   build instead (coherent memory; vkFreeMemory implicitly unmaps).

3. PUNKTFUNK_PERF CSC split. GPU timestamps bracket the compute batch
   (import barriers + cursor prep + CSC dispatch + plane copies) and a
   sampled csc_us line logs every ~2 s — separating shader/copy time from
   the ASIC encode inside the pump's wait_us, the VAAPI submit-split
   analog for this backend. Off (and unrecorded) unless PUNKTFUNK_PERF.

Verified: cargo check + clippy + DPB/RPS unit tests green (Linux box).
The GPU smoke tests still need a RADV host (NVIDIA driver fails session
open on the build box, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:17:34 +02:00
enricobuehler 6dc195f982 fix(encode): Vulkan Video poll() must block per the depth-1 pump contract
apple / swift (push) Successful in 1m20s
windows-host / package (push) Successful in 9m30s
apple / screenshots (push) Successful in 6m39s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 8m45s
decky / build-publish (push) Successful in 20s
android / android (push) Successful in 20m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m4s
ci / rust (push) Successful in 18m50s
deb / build-publish (push) Successful in 12m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 23m15s
deb / build-publish-host (push) Successful in 11m37s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11m4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15m29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13m4s
docker / deploy-docs (push) Successful in 12s
The session pump's depth-1 loop (capture → submit → poll) treats a poll()
None as "the backend holds the frame internally, re-poll next tick" —
true only of the libav AMF/QSV wrappers, whose codecs genuinely hold ~2
frames. The Vulkan Video backend probed its slot fence with
get_fence_status instead, so the AU of every frame — finished on the
ASIC in ~5 ms — sat unharvested until the tick AFTER the next frame was
submitted: encode_us read as one full frame period (~17 ms at 60 Hz vs
VAAPI's ~5.3 ms on the same VCN, the AMD field report) and every frame
shipped a frame period late — one frame of avoidable glass-to-glass
latency on the path that exists to beat libav VAAPI.

Wait the oldest in-flight slot's fence in poll() instead, matching the
sync NVENC backend's blocking lock_bitstream and the documented
Capturer::pipeline_depth contract ("capture → submit → poll-blocks").
Bounded by ENCODE_FENCE_TIMEOUT_NS like enqueue()'s backpressure wait,
for the same reason: poll runs on the thread the stall recovery runs on,
so a wedged GPU must surface as an error, not park it. None now only
means "nothing submitted". Covers H265 and AV1 (shared poll).

Verified: cargo check + DPB/RPS unit tests green (home-worker-5); the
GPU smoke tests fail at session open on that box's NVIDIA driver
identically on unmodified origin/main — pre-existing, needs the RADV
box for on-glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:53:21 +02:00
enricobuehler da259d5b79 feat(plugin-kit): browser-safe ./wire subpath for provider schemas (0.1.1)
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 6m35s
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m14s
android / android (push) Successful in 17m3s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 16m33s
ci / bench (push) Successful in 14m48s
deb / build-publish (push) Successful in 14m48s
ci / rust (push) Successful in 22m36s
deb / build-publish-host (push) Successful in 18m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m25s
Plugin contracts share ProviderEntry/Artwork/LaunchSpec/PrepStep with their
UIs; the kit root stays node-only (reconcile keeps the HostClient transport).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:49:13 +02:00
enricobuehler 4094f6208d fix(web): load the inlang plugin from node_modules, not a CDN
audit / bun-audit (push) Successful in 13s
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 56s
apple / swift (push) Successful in 1m19s
audit / cargo-audit (push) Successful in 3m6s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
apple / screenshots (push) Successful in 6m41s
deb / build-publish (push) Successful in 10m14s
ci / bench (push) Successful in 10m59s
windows-host / package (push) Successful in 11m11s
deb / build-publish-host (push) Successful in 13m54s
android / android (push) Successful in 17m22s
arch / build-publish (push) Successful in 17m37s
ci / rust (push) Successful in 22m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m48s
docker / deploy-docs (push) Successful in 29s
`web/project.inlang/settings.json` pointed `modules` at
https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js —
inlang's stock `paraglide-js init` scaffolding, which nobody had a reason to
keep. It made `bun run codegen` need the network at build time, so in a
sandbox that has none (the Nix build; any air-gapped CI) the plugin import
failed and paraglide compiled ZERO messages.

That failure is silent by design: the inlang SDK logs a PluginImportError as a
WARNING, paraglide still prints "Successfully compiled" and exits 0, vite
bundles the empty message set, and the console only dies at request time inside
renderToReadableStream because every `m.foo()` is undefined. Reported from a
NixOS host running punktfunk-web-server.

The plugin is a normal npm package, so depend on it like one: add
@inlang/plugin-message-format as a devDependency and reference it by relative
path. `loadProjectFromDirectory` (which both `paraglide-js compile` and the
vite plugin use) imports non-`http` modules straight off disk, and the shipped
dist/index.js is a self-contained bundle with no imports of its own, so it
loads from the data: URL exactly as the CDN copy did. Compiles 268 messages
offline, with no project.inlang/cache round-trip.

Add tools/check-i18n.mjs, run at the end of `codegen`, so this can never ship
silently again: it fails the build on a remote `modules` entry, a module that
does not resolve, or a compiled message count below what messages/en.json
defines. Verified it catches all three (remote URL, missing plugin, corrupt
plugin that compiles to 0 messages) and passes a healthy build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:43:39 +02:00
enricobuehler 0ed8ca90f2 fix(plugin-kit): regenerate bun.lock (duplicate package path after react devDep add)
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:41:34 +02:00
enricobuehler 8d72e1d27e feat(plugin-kit): @punktfunk/plugin-kit 0.1.0 — the Effect plugin framework
ci / rust (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
The ~80% of every plugin that was copy-pasted (rom-manager ↔ playnite),
extracted as one Effect-v4 package:

- runtime: definePluginKit — async-main boundary hiding a ManagedRuntime
  (two-effect-instances discipline), signal-driven interruption with a
  bounded shutdown grace
- config: Schema-driven raw round-trip (defaults only in the Schema via
  withDecodingDefaultKey + encodingStrategy omit; file stays authored-shape),
  atomic writes, world-writable refusal, changes stream
- cache-store: disposable derived state, corrupt→empty, write-through
- reconcile: kit-owned provider wire schemas + typed client over the
  skew-safe untyped pf.request seam
- sync-engine: generic poll/watch/debounce/single-flight-coalesce/
  fingerprint-skip engine with a status PubSub (the SSE feed)
- ui-server + sse: effect/unstable/httpapi behind servePluginUi with
  core-only env layers (validated by the phase-0 spikes); raw SSE route
  (httpapi has no event-stream media type)
- cli: minimal command dispatcher reusing the plugin layer graph
  (deliberately not effect/unstable/cli — it needs platform packages)
- react subpath: plugin router (path→hash→fallback deep-link restore +
  pf-ui:navigate bridge), ResultGate, sseAtom, resolvePluginBase
- theme.css: the console's violet identity packaged for plugin UIs

18 bun tests incl. the two phase-0 spikes; publish workflow mirrors
sdk-publish (tag plugin-kit-v*; 0.1.0 published manually).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:41:11 +02:00
enricobuehler c15c80718a chore(sdk): bump @punktfunk/host to 0.1.2
Manual publish (no sdk-v tag — the version was published directly so the
plugin-kit and rewritten rom-manager can consume pluginStateDir/pluginIngestDir
from the registry; the previously published 0.1.1 predates the security-tiers
merge and lacks those exports).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:41:11 +02:00
enricobuehler bb58fde369 feat(plugin-kit): scaffold @punktfunk/plugin-kit + phase-0 spikes
Two spikes validating the riskiest seams of the plugin-kit design before
implementation:

- HttpApi (effect/unstable/httpapi) served on Bun behind the SDK's
  servePluginUi using ONLY effect-core layers (Etag.layerWeak, Path.layer,
  HttpPlatform.layer + FileSystem.layerNoop) — no platform package. Auth,
  schema validation, and static fallthrough all verified end-to-end.
- Browser client prefix strategy for the console proxy: both
  transformClient+prependUrl AND baseUrl preserve the /plugin-ui/<id>
  path prefix. Nested Schema.withDecodingDefaultKey defaults confirmed
  (Effect-valued defaults; encodingStrategy "omit" restores raw shape
  on encode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:41:11 +02:00
enricobuehler de7b54d4e0 fix(core): remaining 15 sweep lows — client, clipboard, C ABI (v10), FEC, GSO, GTK
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 29s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Second and final batch from the 2026-07-20 core-sweep lows:

client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
  completes after the deadline closes with QUIT_CLOSE_CODE instead of
  leaving the host lingering (virtual display up) for a reconnect that
  never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
  send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
  is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
  provably loss-free windows the controller itself scores as fine.

clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
  paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
  an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
  an unanswered FetchRequest parked the task, waiter, and bi-stream
  forever; ~100 of them exhaust the connection's bidi budget).

C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
  offset (Swift/Kotlin latency math read the frozen connect-time value
  ~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
  armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
  aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
  120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
  50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
  defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
  SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).

FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
  encode_into (was: fresh 230×200 generator + decode inversion per
  lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
  (x86 SIMD callees bound stores on input.len() — safe-code OOB write);
  ReconstructShard's safety contract gains the len()==get().len()
  clause and reconstruct_internal sizes raw slices from the slice.

transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
  not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
  process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
  gamepad preference to "auto" on close.

Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:26:43 +02:00
enricobuehler 76ac3cf867 fix(core): five sweep lows — seal-lane fallback, flush partial, codec echo, idle clamp, pair-name UTF-8
From the 2026-07-20 punktfunk-core quality sweep's adjudicated lows
(~/punktfunk-sweeps/punktfunk-core-2026-07-20.json), each with a
regression test:

- session: the two-lane seal's dead-worker fallback dropped the frame's
  back half and returned Ok with half an access unit; the corpse lane
  was also never respawned. The send-failure arm now reclaims the tail
  (single-lane seals the WHOLE frame) and both failure arms drop the
  lane so the next large frame respawns it. A recv-side death now
  surfaces as an error instead of silent truncation.
- reassemble: Reassembler::reset() left pending_partial parked, so a
  pre-flush stale partial survived Session::flush_backlog and was
  delivered as the first "frame" after a jump-to-live.
- caps: resolve_codec echoed a non-conformant multi-bit preferred byte
  verbatim (downstream from_wire folds it to HEVC — possibly outside
  the shared set). It now isolates one bit of the intersection.
- endpoint: stream_transport_idle only floored the value; an absurd
  operator-supplied idle timeout blew past QUIC's VarInt ms ceiling and
  panicked host startup through the expect. Clamped to 1s..1h.
- pairing: PairRequest::encode cut the device name at a raw byte-64
  boundary, splitting multi-byte UTF-8 (host showed U+FFFD forever);
  it now shares Hello's char-boundary truncate_to.

The frozen-FEC-ceiling finding (reassemble.rs:109) was already fixed on
main by the sweep's high-severity commit — skipped as stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:16:02 +02:00
enricobuehler f4ec18d528 chore(core): fix build.rs + crate-doc drift left by the W7/W8 splits
- build.rs: drop the stale `rerun-if-changed=src/client.rs` (the file
  became client/ in W7; the path never fires).
- lib.rs: the crate doc's module list named 6 of ~19 modules — add the
  missing ones (config/error/stats/input/reject/reanchor/render_scale/
  audio/wol + the quic-gated control-plane group), without intra-doc
  links to feature-gated modules.
- quic/mod.rs: the module doc still described the deleted `msgs`
  module; list the actual post-split module set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:08:39 +02:00
enricobuehler ce51a2ba74 refactor(core): decompose the client run_pump monolith into pump/ submodules
client/pump.rs was a single 1192-line async fn stacking six concerns.
It is now a 190-line orchestrator (same spawn order, same channel
wiring) over five focused modules:

- pump/handshake.rs   — connect + Hello/Welcome/Start + skew handshake +
                        hole punch + Session construction (HandshakeOut)
- pump/input_task.rs  — the gamepad snapshot/removal/arrival re-send
                        state machine + passthrough input forwarding
- pump/control_task.rs— the control-stream select loop (renegotiation,
                        probes, bitrate acks, clock re-sync, clip
                        metadata), bundled as ControlTask
- pump/datagram_task.rs — the datagram tag demux + rumble reorder gate
- pump/data.rs        — the blocking data-plane pump (loss reports, ABR
                        + capacity probe, jump-to-live detectors,
                        standing-latency bleed), bundled as DataPump

Every body moved verbatim (dedent + arg-struct destructures aliasing
the original binding names); no per-frame indirection added — the pump
loop, its locals, and the hot-path shape are byte-equivalent. The
Ctrl/DataPump arg structs exist only to stay under too_many_arguments.

196 lib tests pass; clippy clean on --features quic AND
--no-default-features (--all-targets); include/punktfunk_core.h
byte-identical; fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:07:14 +02:00
enricobuehler eacaaa5cd8 refactor(core): peel session.rs anti-replay, perf telemetry, and seal lane into submodules
session.rs (1203) sharpens down to the two hot-path state machines +
lifecycle (887): ReplayWindow + seq_of + their tests -> session/replay.rs;
the PUNKTFUNK_PERF trio PumpPerf/SealPerf/TimedCoder -> session/perf.rs;
the Phase-1.5 lane machinery SealLane/SealJob/seal_wire_slice/
TWO_LANE_MIN_PACKETS -> session/seal.rs. Facade pattern (session.rs stays
the parent file); pub use keeps session::{PumpPerf,SealPerf} stable and
lib.rs re-exports are untouched. Pure code motion + pub(super) bumps —
seal_frame_inner/poll_frame/poll_input bodies unchanged; the
wire-equivalence tests stay co-located with the seal path they pin.

196 lib tests pass, clippy --features quic --all-targets clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:56:26 +02:00
enricobuehler e0a1edb9d4 refactor(core): co-locate the quic wire tests with their modules
quic/tests.rs (1813 lines, 43 tests) was the W7 split's leftover: the
source moved into handshake/caps/control/clock/pairing/pake/datagram/
endpoint/clipstream/io but every test stayed in one monolithic file.
Each test now lives in a #[cfg(test)] mod tests at the foot of the
module it exercises, verbatim. The two CompositorPref/GamepadPref
wire/name tests moved to config.rs (where those enums live), so they
now also run under --no-default-features. The clip_loopback and
ctrl_framing integration mods share connect_pair via a cfg(test)-only
quic/test_util.rs.

Test-only motion: 196 lib tests pass unchanged on macOS, clippy
--features quic --all-targets clean, include/punktfunk_core.h
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:53:36 +02:00
enricobuehler a53369bf21 fix(ci): green main again — clock_sync callers, two clippy denials, an unused import
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m11s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 7m0s
flatpak / build-publish (push) Successful in 6m25s
deb / build-publish (push) Successful in 9m53s
docker / deploy-docs (push) Successful in 28s
release / apple (push) Successful in 9m25s
deb / build-publish-host (push) Successful in 10m0s
windows-host / package (push) Successful in 15m34s
apple / screenshots (push) Successful in 6m30s
arch / build-publish (push) Successful in 18m15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
android / android (push) Successful in 19m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m37s
ci / rust (push) Successful in 26m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m31s
`ci.yml`'s clippy gate has been failing on main since the pre-0.16.0 sweep landed, and
because clippy stops at the first crate it can't compile, the visible error was only
ever the first of four. `ci.yml` is not tag-triggered, so v0.16.0 cut and shipped over
a red main; none of this reaches the release artifacts (the two E0308s are in a test
binary and a dev tool, and the rest are lints) but the gate has been blind since.

Fixed, in the order clippy surfaced them:

- clients/probe failed to COMPILE (E0308, x2). `810d918d` moved `clock_sync` onto the
  resumable `io::MsgReader` but only updated the client pump, leaving the probe passing
  a bare `&mut RecvStream`. The probe now wraps the control stream in a `MsgReader` at
  `open_bi` and threads that everywhere — Welcome, the --remode and --bitrate watchers,
  and the speed-test result read — which is also what the refactor was for: those reads
  sit behind timeouts and `select!`, exactly where a straddling frame would desync the
  stream for the rest of the run.

- pf-capture: `SPA_META_Cursor as u32` is a `u32 -> u32` no-op
  (`clippy::unnecessary_cast`). Line 1274 already passes the same constant uncast, so
  the type is not in question.

- pf-inject: `noop as usize` on the SIGUSR1 wake handler added in `986402f7` trips
  `clippy::function_casts_as_integer`; goes via `*const ()` as the lint asks. Same value
  in the `usize`-typed `sa_sigaction` slot.

- punktfunk-core: the `ctrl_framing` test module's `use super::*` is unused, which
  `-D warnings` promotes to an error.

Verified with CI's own commands on a Linux box (this is all Linux-gated code, so a Mac
cannot check it): `cargo clippy --workspace --all-targets --locked -- -D warnings`
finishes clean, `cargo build --workspace --locked` succeeds, and
`cargo test --workspace --locked` exits 0 with no failures — including punktfunk-core's
196-test suite, which covers the control-stream framing the probe change touches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:49:49 +02:00
enricobuehler 3213c1b61c ci(flatpak): resolve over TCP so the flathub bootstrap stops failing on tag pushes
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 6m43s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Failing after 7m10s
ci / bench (push) Successful in 6m57s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
arch / build-publish (push) Successful in 12m9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 8m55s
android / android (push) Successful in 17m5s
deb / build-publish-host (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m38s
flatpak / build-publish (push) Failing after 8m1s
The flathub remote-add has now burned its entire retry budget and failed the job on
three consecutive tag builds — v0.15.0, the v0.15.0 re-point, and v0.16.0 — each one
needing a manual re-run to land the bundle.

The cause was already root-caused here on 2026-07-11 (see the Tooling step's comment):
home-runner-1's Docker embedded resolver at 127.0.0.11 drops UDP lookups while the
shared multi-org fleet is saturated. Nothing retransmits a dropped datagram, so the
lookup simply times out. The fix then was to widen retry.sh to 10 attempts (~9 min),
which comfortably outlasts a main push's ~8-workflow fan-out — but a TAG push starts
13 workflows at once, and that burst now outlives the whole budget. Retrying harder
is chasing the symptom; the transport is the problem.

`options use-vc` moves resolution to TCP, where the kernel retransmits and a query
cannot be silently lost under load. Same resolver and search path — only the
transport changes — so internal names still resolve exactly as before. Deliberately
no additional nameservers: a public resolver in the list could answer an internal
name (git.unom.io) with NXDOMAIN. retry.sh stays as the backstop for real upstream
blips.

Applied non-fatally, because Docker bind-mounts /etc/resolv.conf and may present it
read-only: a DNS tuning that cannot be applied must not be the thing that fails a
release build. If the write is refused we warn and stay on UDP, i.e. exactly today's
behaviour.

Scoped to the flatpak workflow, where the failure is actually evidenced, rather than
applied fleet-wide on suspicion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:33:34 +02:00
enricobuehler b3adfb3a56 ci(release): pin Xcode DerivedData so it stops filling the mac runner's disk
apple / swift (push) Successful in 1m23s
release / apple (push) Successful in 9m19s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
apple / screenshots (push) Successful in 6m31s
ci / rust (push) Failing after 6m18s
ci / bench (push) Successful in 5m7s
deb / build-publish (push) Successful in 8m58s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 21s
arch / build-publish (push) Failing after 16m42s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
android / android (push) Successful in 18m40s
deb / build-publish-host (push) Successful in 9m25s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m36s
v0.16.0's Apple leg failed at the xcframework build with "No space left on
device (os error 28)": the runner's boot volume was at 100% with 152 MB free.

None of the four `xcodebuild archive` calls passed -derivedDataPath, so each one
used the default ~/Library/Developer/Xcode/DerivedData/<name>-<hash> — and that
hash is derived from the PROJECT'S ABSOLUTE PATH. act_runner rotates its
workspace (~/.cache/act/<hash>/hostexecutor), so every rotation looked like a new
project to Xcode and minted a fresh ~760 MB tree. Nothing ever collects those:
they live outside the workspace, so act's own cleanup never sees them. 31 had
accumulated in three days (17 -> 20 July), which with the 12 GB shared
ModuleCache came to 32 GB — on a 228 GB volume already 95% full.

Pin all four archives to one path. The tree is now REUSED rather than multiplied,
which also keeps the module cache warm instead of rebuilding it per run. The new
step additionally prunes anything week-stale left in the default root, covering
both the legacy per-path trees and any other job that lands there.

Cleared by hand on home-mac-mini-1 to unblock the release (152 MB -> 31 GB free);
this is the change that stops it coming back. Note the same class of problem bit
the Windows runner the same night from the other direction — its disk-cleanup task
purges C:\t and deleted files out from under ISCC mid-pack — which is worth its
own look and is NOT addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:17:33 +02:00
88 changed files with 8604 additions and 3501 deletions
+28 -2
View File
@@ -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.
+69
View File
@@ -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
+24
View File
@@ -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" \
+9 -1
View File
@@ -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
View File
@@ -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");
+1 -2
View File
@@ -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;
} }
+14
View File
@@ -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
+3 -3
View File
@@ -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);
+462 -26
View File
@@ -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 13 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");
}
} }
+33 -6
View File
@@ -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,8 +116,7 @@ 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 {
@@ -103,13 +128,15 @@ pub(crate) unsafe fn import_rgb_dmabuf(
.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(vk::ImageUsageFlags::SAMPLED) .usage(usage)
.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);
None, if let Some(pl) = profile_list {
)?; 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
+35
View File
@@ -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
+6
View File
@@ -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());
+74 -21
View File
@@ -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). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving // context current (documented). `&copy` 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(&copy, "cuMemcpy2DAsync_v2(dev->dev)") } // lifetime obligation to the caller (documented above). Wrapper → live table.
unsafe { copy_issue(&copy, "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). `&copy` is a live local outliving the synchronous call; // context current (documented). `&copy` 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(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? }; // above). Wrapper → live table.
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
} }
Ok(()) Ok(())
} }
+82 -10
View File
@@ -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 mut try_priority = gp_ext.map(|(_, want)| want);
let device = loop {
let prio = [1.0f32]; let prio = [1.0f32];
let qci = [vk::DeviceQueueCreateInfo::default() let mut gp_info = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default()
.global_priority(try_priority.unwrap_or(vk::QueueGlobalPriorityKHR::MEDIUM));
let mut qci0 = vk::DeviceQueueCreateInfo::default()
.queue_family_index(qf) .queue_family_index(qf)
.queue_priorities(&prio)]; .queue_priorities(&prio);
let device = instance let mut exts: Vec<*const std::ffi::c_char> = base_exts.to_vec();
.create_device( 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);
-1
View File
@@ -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");
+84 -16
View File
@@ -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,24 +468,32 @@ 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 mut count = 0i32;
loop {
// Narrow scope: re-derive the handle and pull ONE event, then drop the borrow
// before dispatching. The callback may legally re-enter `punktfunk_*` on this
// handle (get_stats, send_input, clearing the callback) — with a `&mut` held
// across the call that re-entry aliased it (UB under noalias). Re-reading
// `input_cb` per iteration also makes a mid-drain
// `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() } { let s = match unsafe { s.as_mut() } {
Some(s) => s, Some(s) => s,
None => return PunktfunkStatus::NullPointer as i32, None => return PunktfunkStatus::NullPointer as i32,
}; };
let cb = s.input_cb;
let mut count = 0i32;
loop {
match s.inner.poll_input() { match s.inner.poll_input() {
Ok(Some(ev)) => { Ok(Some(ev)) => (ev, s.input_cb),
Ok(None) => break,
Err(e) => return e.status() as i32,
}
};
if let Some((cb, user)) = cb { if let Some((cb, user)) = cb {
cb(&ev as *const InputEvent, user); cb(&ev as *const InputEvent, user);
} }
count += 1; count += 1;
} }
Ok(None) => break,
Err(e) => return e.status() as i32,
}
}
count count
})); }));
r.unwrap_or(PunktfunkStatus::Panic as i32) r.unwrap_or(PunktfunkStatus::Panic as i32)
@@ -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,
+9 -1
View File
@@ -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 67 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());
}
}
}
}
}
}
+115 -4
View File
@@ -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,14 +158,39 @@ 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
// FetchRequest event is emitted, so a live serve always finds it):
// bytes served under a stale/unknown/cancelled req_id would otherwise
// pool here for the whole session with Ok returned for every chunk.
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 { if last {
let full = serve_bufs.remove(&req_id).unwrap_or_default(); let full = serve_bufs.remove(&req_id).unwrap_or_default();
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) { if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id)
{
let _ = tx.send(Some(full)); let _ = tx.send(Some(full));
} }
} }
} }
}
}
ClipCommand::Cancel { id } => { ClipCommand::Cancel { id } => {
// Route to whichever table owns the id (they are disjoint by the high bit). // Route to whichever table owns the id (they are disjoint by the high bit).
if let Some(tx) = fetch_cancels.remove(&id) { if let Some(tx) = fetch_cancels.remove(&id) {
@@ -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);
}
}
+96
View File
@@ -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);
}
} }
+19
View File
@@ -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);
} }
// Same (k, m)-keyed cache as `encode_into`: a fresh ReedSolomon per lossy block costs a
// 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) let rs = ReedSolomon::new(data_count, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .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());
} }
// Cache the codec by (k, m) exactly as `encode_into`/`reconstruct` do (see the note
// 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) let rs = ReedSolomon::new(data_count, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .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() {
+15 -1
View File
@@ -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()"
);
}
}
+78 -1
View File
@@ -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();
}
}
+112
View File
@@ -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 (t3t2) 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));
}
}
+366
View File
@@ -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());
}
}
+318
View File
@@ -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 -2
View File
@@ -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);
}
}
+601 -2
View File
@@ -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());
}
}
+81
View File
@@ -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();
}
}
+9 -6
View File
@@ -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
//! 0xC90xCF 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 0xC90xCF 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;
+53 -3
View File
@@ -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"
);
}
}
+33
View File
@@ -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
+69 -330
View File
@@ -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,7 +372,8 @@ impl Session {
ns: 0, ns: 0,
result: Ok(()), result: Ok(()),
}; };
if lane.to_worker.send(job).is_ok() { match lane.to_worker.send(job) {
Ok(()) => {
// Seal the front half while the worker runs; collect BOTH results // Seal the front half while the worker runs; collect BOTH results
// before erroring so the lane is always drained and reusable. // before erroring so the lane is always drained and reusable.
let t0 = perf_armed.then(std::time::Instant::now); let t0 = perf_armed.then(std::time::Instant::now);
@@ -522,10 +381,9 @@ impl Session {
if let Some(t0) = t0 { if let Some(t0) = t0 {
seal_ns += t0.elapsed().as_nanos() as u64; seal_ns += t0.elapsed().as_nanos() as u64;
} }
let mut done = lane match lane.from_worker.recv() {
.from_worker Ok(mut done) => {
.recv() *seal_lane = Some(lane);
.map_err(|_| PunktfunkError::Unsupported("seal lane died"))?;
seal_ns += done.ns; seal_ns += done.ns;
wires.append(&mut done.bufs); wires.append(&mut done.bufs);
*lane_scratch = done.bufs; *lane_scratch = done.bufs;
@@ -533,7 +391,23 @@ impl Session {
done.result?; done.result?;
split_done = true; split_done = true;
} }
// A failed send means the worker is gone — fall through to single-lane. 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);
}
}
} }
} }
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);
}
}
+98
View File
@@ -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)
}
}
+175
View File
@@ -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);
}
}
+74
View File
@@ -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) {
+24
View File
@@ -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
View File
@@ -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.
+26 -4
View File
@@ -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,6 +2077,7 @@ 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 {
if cur_depth < max_depth {
cur_depth = max_depth; cur_depth = max_depth;
tracing::info!( tracing::info!(
depth = cur_depth, depth = cur_depth,
@@ -2076,6 +2085,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
(GPU contention); pipelining for the rest of the session (latency \ (GPU contention); pipelining for the rest of the session (latency \
trade for throughput)" 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;
} }
} }
} }
+26 -7
View File
@@ -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
+9 -1
View File
@@ -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
+60
View File
@@ -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.
+205
View File
@@ -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=="],
}
}
+5
View File
@@ -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/"
+61
View File
@@ -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"
}
}
+68
View File
@@ -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>;
});
+88
View File
@@ -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();
}
};
+140
View File
@@ -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 }))
// }
+49
View File
@@ -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;
}> {}
+53
View File
@@ -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);
+46
View File
@@ -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";
+24
View File
@@ -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}`,
);
}),
]);
+45
View File
@@ -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);
+143
View 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;
});
+42
View File
@@ -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;
}),
);
}
+134
View File
@@ -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();
}
};
+64
View File
@@ -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",
},
}),
),
);
};
+285
View File
@@ -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>;
});
+264
View File
@@ -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);
}
+88
View File
@@ -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),
);
});
+34
View File
@@ -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;
+143
View File
@@ -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 } },
]);
});
});
+143
View File
@@ -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([]);
});
});
+153
View File
@@ -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();
}
});
});
+79
View File
@@ -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");
});
});
+35
View File
@@ -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();
}
});
});
+128
View File
@@ -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);
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+14
View File
@@ -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
View File
@@ -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",
+5
View File
@@ -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
View File
@@ -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",
+1 -3
View File
@@ -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"
} }
+3 -3
View File
@@ -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}
+80
View File
@@ -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(", ")}`,
);