From 671ad6f22e4c221a7bd706d6dccd755f4740163d Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:14:07 +0800 Subject: [PATCH] test(native): gate one-viewer delivery evidence (#67) --- .../0006-fixed-high-native-sender-canary.md | 61 +- docs/project-memory.md | 3 +- docs/research/native-shared-encode-sender.md | 103 +- docs/status.md | 16 +- .../cmd/screener-one-viewer-gate/main.go | 31 + package.json | 1 + scripts/native-one-viewer-gate-core.ts | 176 ++++ scripts/native-one-viewer-gate.ts | 977 ++++++++++++++++++ scripts/native-one-viewer-ledger.ts | 157 +++ tests/native-one-viewer-gate-core.test.ts | 195 ++++ tests/native-one-viewer-ledger.test.ts | 117 +++ tsconfig.tools.json | 8 +- 12 files changed, 1822 insertions(+), 23 deletions(-) create mode 100644 native/sender/cmd/screener-one-viewer-gate/main.go create mode 100644 scripts/native-one-viewer-gate-core.ts create mode 100644 scripts/native-one-viewer-gate.ts create mode 100644 scripts/native-one-viewer-ledger.ts create mode 100644 tests/native-one-viewer-gate-core.test.ts create mode 100644 tests/native-one-viewer-ledger.test.ts diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md index f2b7225..4b347f5 100644 --- a/docs/adr/0006-fixed-high-native-sender-canary.md +++ b/docs/adr/0006-fixed-high-native-sender-canary.md @@ -52,14 +52,67 @@ authentication failure gets one retry with the same room/token/client generation and never another room POST. Server restart drops the room; SQLite stays at v2 and ordinary Web rooms are unchanged. No claim-confirm message, durable native token, or recovery subsystem is added. Focused Go/TypeScript tests and static checks pass. The unused -generic probe framework is deliberately not retained. -No runtime/browser revalidation has run, so the product gate remains no-go and -this local branch is not a product availability claim. +generic probe framework is deliberately not retained. A current command consumer +may keep only the minimum stage ledger needed by the next authorized run. + +The first authorized current-wire run on 2026-08-20 remains +`no-go-unclassified`. It used Windows amd64, Chrome 151, one real animated Chrome +tab, and an isolated local Node server with an in-memory RoomStore and peer +assistance disabled. Safe Chrome history booleans prove that the source and +Sender pages loaded and that no Viewer page was created. The runner stopped +after a bounded 20-second Sender-start interval without observing the combined +Sender-ready and Go source-RTP condition. + +The timeout path failed to preserve its final Sender DOM/counter sample. It +therefore cannot distinguish capture selection, Host admission/create, WSS +authentication, local bridge, first encoded output, or Go ingest as the first +runtime break. None is a proven product failure. No retry, timeout adjustment, +Viewer, second Viewer, or FIFO run followed. A separately authorized rerun must +first make the stage-1 ledger unconditional and prove that timeout retains the +last sanitized checkpoint; only a passed Sender/source-RTP stage may create the +single Viewer. The product gate remains no-go and this local branch is not a +product availability claim. + +The retained current command consumer appends and flushes only monotonic, +sanitized Sender-start fields. Its static one-Viewer gate now freezes one +socket/auth/connection/PeerConnection/video-track generation and the matching +Pion slot/edge generation, then revalidates that identity during media progress; +raw connection IDs never leave the page probe. Every CDP RPC and sample shares +its enclosing absolute deadline. `passed` is impossible until bounded Chrome +and native process-tree exit, listener closure, and audited deletion of the +exact task profile under system Temp all succeed; a reparse point or deletion +failure is a fixed cleanup failure. Pure checks cover retained timeout evidence, +hung samples, cross-generation rejection, cleanup-before-pass, and profile +cleanup failure. + +One separately authorized second run then used that frozen gate with the same +bounded local topology. Its retained ledger proves `getDisplayMedia` request and +resolution, Host admission and room creation, Host WSS authentication, local +bridge readiness and fixed-`HIGH` config acceptance, exactly one encoder object, +and entry into the first WebCodecs output callback. It retained zero Go +`framesWritten`, source-RTP packets, and source-RTP bytes, so it stopped at +`sender-start` and never created a Viewer. No retry or threshold adjustment +followed. Cleanup completed before final failure: Chrome and Native exited, the +Node server and all three loopback ports closed, and the exact task Chrome +profile passed the non-reparse audit and was removed. + +This remains `no-go-unclassified`. The frozen probe counted the first encoded +chunk before invoking the product output callback and did not record whether the +generation-bound local bridge attempted, returned from, or threw during its +binary send. Static inspection finds matching 17-byte big-endian frame contracts +on the Web and Go sides, but cannot convert that into runtime evidence. The proven +break interval is therefore after WebCodecs output callback entry and before Go +frame/source-RTP accounting, not a proven product bridge defect. The current +unrun gate now records only bounded attempt/synchronous-return/synchronous-throw +counters for the first local bridge generation and rechecks that identity at +Viewer signal, Viewer media, and final success. Pure tests reject late +replacement. A further run requires separate authorization. There is still no +Viewer, two-viewer, FIFO, or physical/hardware-encoder proof. ## Staged Revalidation A new run requires separate authorization and must stop at the first failed -stage while retaining a bounded final-negative snapshot: +stage while synchronously retaining a bounded final-negative snapshot: 1. Prove host room/auth, local config acknowledgement, one encoder object, bridge ingress, frame decode, source RTP, and zero critical errors. diff --git a/docs/project-memory.md b/docs/project-memory.md index 3202619..e6c9fee 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -23,7 +23,7 @@ Last updated: 2026-08-20 - Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an isolated exact-room candidate until resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile gates pass. - Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost. - Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate. -- ADR-0006 stays no-go. Its v2 room is memory-only, capped by `ROOM_TTL` and rearmed 300s after Host disconnect; SQLite/Web unchanged. Pre-auth retries once in-room without another POST. +- ADR-0006 stays no-go: the second run reached first WebCodecs output, then zero Go frame/RTP and no Viewer; cleanup passed 5/5. Its unrun gate rechecks one Viewer/Pion/bridge generation and cleanup-before-pass. No product bug or further run is authorized; Native v2 stays memory-only and leaves SQLite/Web unchanged. - Mobile Web Host is unsupported; feature-detect and fail clearly. Mobile Viewer stays leaf-only. After Windows native, gate Android 14+; iOS waits for stable iOS 27 ScreenCaptureKit. - ADR-0005 accepts direct-first Peer ICE and bounded SFU roots. Its default-off exact-room slice is undeployed; production/coturn stay STUN-only. PR #12/selected-edge superseded. - Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback. @@ -48,7 +48,6 @@ Last updated: 2026-08-20 - Production logs show two root participants and two short Host participants with no service restart or retained track. Their timing is consistent with, but does not directly prove, one fresh-grant retry and Peer failback. The Host exposes only the local current-revision failure stage (`connect|source|video-publish|sender-config|audio-publish|transport`) and uploads no raw error or endpoint data. Browser relays still re-encode; native capture/hardware remains outside validated production behavior. - The retained v1 backup is the only schema rollback path. A failed artifact attempt exposed unsafe hard-linked dependency reuse; the successful immutable release has zero shared regular-file inodes with rollback, and future releases preserve that boundary. - Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence. -- Source `main` at `f5d48ed` includes a memory-only Native v2 candidate, but production predates it and has no Native runtime gate. Render, hardware, two-edge/FIFO and TURN evidence remain absent. - Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only. - Room `1` publishes `HIGH+LOW` with Dynacast off and subscriber `HIGH` ceilings, and now has bounded local quality reparenting. Both remain unverified on real media; test zero-child leaves and calibrated C+B loss before broad rollout. - Generation-bound C+B needs three hard-bad windows; parent samples are one-use per connection. Pending SFU binds intent/guard. Stale/successful peer work releases quality exclusion; real failure may take it over. It uploads no raw endpoint metadata and computes no score. diff --git a/docs/research/native-shared-encode-sender.md b/docs/research/native-shared-encode-sender.md index 2b5e926..1d17e6c 100644 --- a/docs/research/native-shared-encode-sender.md +++ b/docs/research/native-shared-encode-sender.md @@ -4,7 +4,7 @@ - Scope: one Windows game-capture sender, one encoded video stream, and at most two independent standard WebRTC media edges - Status: stacked Draft PRs #16/#18/#22/#23/#25/#28 pass one bounded live - two-leg WebCodecs/Pion candidate. The first product-wiring gate is + two-leg WebCodecs/Pion candidate. Both product-wiring attempts remain no-go-unclassified; product and physical shared encoding remain unproven. ## Decision Input @@ -178,12 +178,93 @@ retry without a second room POST. SQLite remains v2 and ordinary Web creation is unchanged. HTTP statuses, Cookie attributes, create-room responses, Host authentication, and ignored Viewer evidence are strict and bounded; errors retain only fixed categories. -Focused Go and TypeScript checks pass, but no Node/Chrome runtime gate has run. -The overbroad unused gate/probe framework was deleted; a future authorized run -must add only its executable minimum. This static checkpoint does not change the -no-go result below. +Focused Go and TypeScript checks pass. The overbroad unused gate/probe framework +stays deleted; the current command consumer is limited to the executable runner +and sanitized stage ledger required by the next authorized run. -The sole authorized 2026-08-19 local product gate is +The one authorized 2026-08-20 current-wire attempt used Chrome 151 on Windows +amd64, a temporary non-default Chrome profile, a real animated Chrome tab, and +an isolated local Node server with an in-memory RoomStore and peer assistance +disabled. It did not contact production or SQLite. Retained history booleans +show that the source and Sender pages loaded and no Viewer page was created. The +runner reported `failedStage=node-host` after its bounded 20-second Sender-start +interval because it did not observe the combined Sender-ready and Go source-RTP +condition. + +This is `no-go-unclassified`, not evidence that the Node Host path itself +failed. The timeout branch did not retain a final Sender DOM/counter sample, so +it cannot locate the break among `getDisplayMedia` request/resolve, Host +admission/create, WSS authentication, local bridge connection, first encoded +chunk, or Go source RTP. There was no retry or timeout adjustment. Viewer auth, +offer/answer/ICE, Pion bound-edge output, Viewer inbound/decode/render, two-edge, +and FIFO evidence were never attempted. A later run needs separate authorization +and an unconditional stage-1 final-negative ledger before it may create one +Viewer. + +That local-only runner is now the current `npm run gate:native-one-viewer` +consumer. It requires an explicit Chrome executable environment path and uses +the task-local Go executable or task-specific `PATH`; neither path is committed. +Each monotonic Sender-start transition appends and flushes one bounded JSON +record containing only booleans and capped counters. A pure timeout test proves +that capture, admission/create, Host WSS, bridge, fixed-`HIGH`, first encoded +chunk, Go-ingest, and source-RTP state cannot be erased by a later failed sample. +The one-Viewer signaling/media waits likewise preserve their latest sanitized +sample in the final report. They now accept evidence only from one frozen +signaling socket/auth/opaque-connection ordinal, one PeerConnection/video-track +ordinal, and the same Pion slot/edge generation across signal and media stages. +The raw connection ID remains page-local. Every CDP RPC/sample is capped by the +remaining stage deadline. Final status is written only after bounded process-tree +exit and closed-port polling, followed by an exact system-Temp path audit, +recursive reparse-point rejection, profile deletion, and absence verification; +any cleanup failure is fail-closed. Microsoft documents `/T` as terminating a +task's child processes and `ReparsePoint` as the filesystem attribute used for +these special entries. Pure tests cover hung sampling, cross-PC evidence, +cleanup-before-pass, profile path bounds, and deletion failure. Sources were +rechecked 2026-08-20. + +One separately authorized second current-wire run used the frozen gate once and +did not retry or adjust a threshold. Its append-and-flush ledger reached sequence +7 and retained positive booleans for capture request/resolution, Host admission, +private room creation, Host WSS authentication, bridge readiness, fixed VP8 +1280x720@30/3 Mbps config acceptance, one encoder object, and entry into the +first WebCodecs output callback. Go diagnostics remained at zero +`framesWritten`, source-RTP packets, and source-RTP bytes. The runner therefore +failed closed at `sender-start`; it never created the Viewer page and did not +exercise Viewer auth, SDP/ICE, Pion bound-edge output, decode/render, a second +Viewer, or FIFO. Cleanup was complete before the report was finalized: Chrome +and Native exited, the isolated Node listener and all three random loopback +ports closed, and the exact task profile passed a non-reparse audit and was +removed. + +The result narrows but does not classify the break. The gate increments its +encoded-output observation before it calls the product output callback, and that +run did not retain whether the current local media WebSocket attempted, returned +from, or threw during the binary frame send. Static source comparison shows a +matching binary message type, 17-byte big-endian header, one-MiB payload limit, +and VP8 frame decode on both sides. It also confirms that Pion's unbound static RTP track +returns no error. Those facts do not prove which runtime operation followed the +callback. The retained first break interval is after WebCodecs output callback +entry and before Go frame/source-RTP accounting. There is no uniquely proven +product bug. The current unrun probe now binds the first actual local media +WebSocket as gate-local generation 1 and records only bounded binary-send +attempt, synchronous-return, and synchronous-throw counters. A second bridge +generation saturates at 2, stops accumulating the first generation's counters, +and fails the Viewer signal, Viewer media, and final-success checks even after +Sender-start passed. A pure in-memory test covers success, throw, and late +replacement without retaining a URL, payload, token, SDP, IP address, or raw +error. No subsequent browser/media run is authorized or has occurred. + +Chromium documents the tab-capture auto-selection switch as a test-only aid, +and Chrome requires a non-default user-data directory for remote debugging from +Chrome 136. Those constraints explain the isolated harness setup; they do not +prove that `getDisplayMedia` resolved in the first failed current-wire run. +Static inspection of the pinned Pion v4.2.18 `TrackLocalStaticRTP.WriteRTP` shows +that it iterates the +current bindings and returns no error for an empty binding set. This excludes a +pre-Viewer no-binding write as the likely fatal break; it is not runtime proof +of Go ingest or source RTP. Sources were rechecked 2026-08-20. + +The earlier authorized 2026-08-19 local product gate is `no-go-unclassified`: | Checkpoint | Retained evidence | @@ -309,8 +390,9 @@ diversity, lifecycle, and sustained CPU/GPU, memory, latency, quality, and upload measurements. A third host edge, custom RTP/SRTP, or a custom congestion-control framework remains out of scope. The #28 minimum-of-two policy is also out of scope for product code; retaining its -evidence does not retain its policy. Selected-edge TURN is a separate future -contract and gate, not a prerequisite silently added to this STUN-only canary. +evidence does not retain its policy. Built-in exact-room Peer ICE TURN +supersedes selected-edge as a separate Web source canary; Native stays STUN-only +unless its own bounded capability and transport gate is accepted. ## Primary Sources And License Boundary @@ -328,6 +410,7 @@ contract and gate, not a prerequisite silently added to this STUN-only canary. - [WebRTC video codec requirements, RFC 7742](https://www.rfc-editor.org/rfc/rfc7742.html) - [WebRTC congestion control requirements, RFC 8836](https://www.rfc-editor.org/rfc/rfc8836.html) - [Pion track fanout implementation](https://github.com/pion/webrtc/blob/main/track_local_static.go) +- [Pion v4.2.18 pinned track implementation](https://github.com/pion/webrtc/blob/v4.2.18/track_local_static.go) - [Pion send-side bandwidth estimator](https://github.com/pion/interceptor/blob/main/pkg/gcc/send_side_bwe.go) - [Pion WebRTC license](https://github.com/pion/webrtc/blob/main/LICENSE) - [WebCodecs](https://www.w3.org/TR/webcodecs/) @@ -337,6 +420,10 @@ contract and gate, not a prerequisite silently added to this STUN-only canary. - [W3C WebRTC simulcast](https://www.w3.org/TR/webrtc/#simulcast-functionality) - [Pion WebRTC v4.2.18](https://github.com/pion/webrtc/tree/v4.2.18) - [Pion Interceptor v0.1.47](https://github.com/pion/interceptor/tree/v0.1.47) +- [Chromium Chrome test switches](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/chrome/common/chrome_switches.cc) +- [Chrome remote-debugging profile requirement](https://developer.chrome.com/blog/remote-debugging-port) +- [Microsoft `taskkill`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill) +- [Microsoft `FileAttributes.ReparsePoint`](https://learn.microsoft.com/en-us/dotnet/api/system.io.fileattributes?view=net-10.0) - [RFC 4585 RTP/AVPF feedback](https://www.rfc-editor.org/rfc/rfc4585.html) - [RFC 4588 RTP retransmission](https://www.rfc-editor.org/rfc/rfc4588.html) - [RFC 8888 congestion-control feedback](https://www.rfc-editor.org/rfc/rfc8888.html) diff --git a/docs/status.md b/docs/status.md index 96b2211..b88e2a1 100644 --- a/docs/status.md +++ b/docs/status.md @@ -11,7 +11,7 @@ Ordinary ICE is STUN-only process-wide; persistent room `1` alone enables the automatic peer/SFU-UDP controller with at most two SFU roots. The old `05f98d10ecd1` is the immediate rollback with the current v2 DB/env. Crossing the admission cutover requires `89e6d7649169` plus its recorded old environment; -only deeper pre-access `9610032` may restore v1 state. Source `main` at `f5d48ed` +only deeper pre-access `9610032` may restore v1 state. Source `main` at `a11a73d` also contains a no-go Native v2 candidate, but production does not run it. ## Current Snapshot @@ -19,7 +19,7 @@ also contains a no-go Native v2 candidate, but production does not run it. - Capture precedes room creation; source/quality changes preserve peers and pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. - Production requires `HOST_ADMISSION_PASSWORD` only for creation/Host role and uses `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Viewers never need the Host secret; there is no account/session table or old parser. - SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row. The stopped migration retained four rooms and locked each old room private without minting a raw grant. -- Source-only Native v2 uses memory-only rooms, 300s post-disconnect reclaim and `ROOM_TTL`; it has no production gate. +- Source-only Native v2 uses memory-only rooms, 300s reclaim and `ROOM_TTL`; production does not run it. - PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP. - Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky. - After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified. @@ -37,7 +37,7 @@ also contains a no-go Native v2 candidate, but production does not run it. - Host A+B and authenticated P2P Viewer C are sanitized, generation-bound, read-only, and fail closed for stale, ambiguous, or SFU-fed evidence; no raw media metadata is retained. - SVC is `no-go-web-svc-cross-path-hardware-contract`: no direct/peer selection, cross-PC shared encode, or portable hardware proof; pinned screen share is `L1T3`. No browser run was warranted. - Source TURN tests pass; production/coturn stay STUN-only and untested. -- Draft #16/#18/#22/#23/#25/#28 passed isolated experiments only. ADR-0006 reached host setup/one encoder output, then timed out before viewer 1 decoded/rendered. +- Native remains experimental. The second 2026-08-20 run reached first WebCodecs output, then zero Go frame/RTP and no Viewer; cleanup passed 5/5. No product bug or retry followed. - Access focused tests pass protocol/config/HTTP/storage/RoomStore/SQLite/signaling, including commit-first teardown, persistence-failure continuity, relay/SFU retirement, grant bounds, and v1 migration rollback. ## Unverified Boundaries @@ -47,7 +47,7 @@ also contains a no-go Native v2 candidate, but production does not run it. - Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified. - Real audio and heterogeneous clients remain unverified. Production reports poor film audio and voice-call self-echo under system capture; there is no app audio ceiling or Web process isolation. Diagnose A/B/C and sync, then gate Windows 11 game-process audio; Windows 10 stays unresolved without system fallback. - The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it. -- ADR-0006 stays no-go: its v2 memory-only candidate passed static checks only; render, two-edge/FIFO and later TURN remain unverified. +- ADR-0006 stays no-go: its runtime gap is WebCodecs output -> Go frame/RTP; the unrun bridge-generation probe rechecks signal/media/final. Viewer, two-edge/FIFO, and Native TURN remain unverified. - Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges. - PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go. - The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. @@ -62,16 +62,16 @@ The recovery target uses one ICE restart, one same-parent rebuild, one alternate SFU; it never runs three identical retries or abandons progressing P2P early. Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root -evacuation. Native v2 remains no-go pending an authorized runtime gate; Audio -A/B/C may proceed without displacing P0. +evacuation. Native needs separate authorization; its next run gates Go RTP on +the first bridge send. +Audio A/B/C may proceed without displacing P0. Peer ICE TURN supersedes selected-edge but remains source-only pending an isolated coturn canary. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate 20-viewer gate must pass before the current default eight changes to target 20. -ADR-0004 fails closed. Native shared encode and packet/layer striping remain -separate experiments, not ways to relabel a failed browser route. +ADR-0004 remains fail-closed; Native/striping experiments cannot relabel it. ## Blockers And Decisions diff --git a/native/sender/cmd/screener-one-viewer-gate/main.go b/native/sender/cmd/screener-one-viewer-gate/main.go new file mode 100644 index 0000000..7c72f34 --- /dev/null +++ b/native/sender/cmd/screener-one-viewer-gate/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + + "github.com/TNTcraftHIM/Screener/native/sender/internal/app" +) + +func main() { + sender, err := app.New() + if err != nil { + fmt.Fprintln(os.Stderr, "sender initialization failed") + os.Exit(1) + } + defer sender.Close() + + launchURL, err := sender.Start() + if err != nil { + fmt.Fprintln(os.Stderr, "sender startup failed") + os.Exit(1) + } + if err = json.NewEncoder(os.Stdout).Encode(map[string]string{"launchUrl": launchURL}); err != nil { + fmt.Fprintln(os.Stderr, "sender handoff failed") + os.Exit(1) + } + + _, _ = bufio.NewReader(os.Stdin).ReadByte() +} diff --git a/package.json b/package.json index 0c9586d..0f5ee1d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:server": "tsc -p tsconfig.server.json", "start": "node dist/server/server/index.js", "benchmark:peer-assisted": "tsx scripts/peer-assisted-benchmark.ts", + "gate:native-one-viewer": "tsx scripts/native-one-viewer-gate.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/native-one-viewer-gate-core.ts b/scripts/native-one-viewer-gate-core.ts new file mode 100644 index 0000000..da6a7c1 --- /dev/null +++ b/scripts/native-one-viewer-gate-core.ts @@ -0,0 +1,176 @@ +import { basename, dirname, resolve } from "node:path"; + +export interface ViewerIdentityEvidence { + socketCount: number; + socketOrdinal: number; + authGeneration: number; + connectionCount: number; + connectionOrdinal: number; + pcCount: number; + pcOrdinal: number; + trackCount: number; + trackOrdinal: number; + consistent: boolean; +} + +export interface SenderIdentityEvidence { + peerCount: number; + maxPeers: number; + slot: number; + edgeGeneration: number; +} + +export interface BridgeSendEvidence { + bridgeGeneration: number; + binarySendAttempts: number; + binarySendSucceeded: number; + binarySendFailed: number; +} + +export interface OneViewerIdentity { + socketOrdinal: number; + authGeneration: number; + connectionOrdinal: number; + pcOrdinal: number; + trackOrdinal: number; + pionSlot: number; + pionEdgeGeneration: number; +} + +export interface CleanupResult { + browserExited: boolean; + nativeExited: boolean; + serverClosed: boolean; + portsClosed: boolean; + profileRemoved: boolean; +} + +interface FinalizableReport { + status: "passed" | "failed"; + failedStage: string | null; + stages: Record | undefined>; +} + +export function captureOneViewerIdentity( + viewer: ViewerIdentityEvidence, + sender: SenderIdentityEvidence, +): OneViewerIdentity | null { + if (!viewer.consistent || viewer.socketCount !== 1 || viewer.authGeneration !== 1 || + viewer.connectionCount !== 1 || viewer.pcCount !== 1 || viewer.trackCount !== 1 || + sender.peerCount !== 1 || sender.maxPeers > 1 || sender.edgeGeneration !== 1 || + viewer.socketOrdinal !== 1 || viewer.connectionOrdinal !== 1 || viewer.pcOrdinal !== 1 || + viewer.trackOrdinal !== 1 || sender.slot <= 0) return null; + return { + socketOrdinal: viewer.socketOrdinal, + authGeneration: viewer.authGeneration, + connectionOrdinal: viewer.connectionOrdinal, + pcOrdinal: viewer.pcOrdinal, + trackOrdinal: viewer.trackOrdinal, + pionSlot: sender.slot, + pionEdgeGeneration: sender.edgeGeneration, + }; +} + +export function retainsOneViewerIdentity( + baseline: OneViewerIdentity, + viewer: ViewerIdentityEvidence, + sender: SenderIdentityEvidence, +): boolean { + const current = captureOneViewerIdentity(viewer, sender); + return current !== null && Object.keys(baseline).every( + (key) => current[key as keyof OneViewerIdentity] === baseline[key as keyof OneViewerIdentity], + ); +} + +export function retainsFirstBridgeSend(evidence: BridgeSendEvidence): boolean { + return evidence.bridgeGeneration === 1 && evidence.binarySendAttempts > 0 && + evidence.binarySendSucceeded > 0 && evidence.binarySendFailed === 0; +} + +export function withDeadline( + operation: () => Promise, + deadline: number, + now: () => number = Date.now, +): Promise { + const remaining = Math.max(0, deadline - now()); + return new Promise((resolveValue, rejectValue) => { + const timeout = setTimeout(() => rejectValue(new Error("Operation deadline exceeded")), remaining); + Promise.resolve().then(operation).then( + (value) => { clearTimeout(timeout); resolveValue(value); }, + (error: unknown) => { clearTimeout(timeout); rejectValue(error); }, + ); + }); +} + +type JsonFetch = ( + input: string, + init: { signal: AbortSignal }, +) => Promise<{ ok: boolean; json(): Promise }>; + +export async function fetchJsonBefore( + url: string, + deadline: number, + fetcher: JsonFetch = fetch, +): Promise { + const controller = new AbortController(); + const abort = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); + try { + const response = await withDeadline( + () => fetcher(url, { signal: controller.signal }), + deadline, + ); + if (!response.ok) throw new Error("HTTP response was not successful"); + return await withDeadline(() => response.json() as Promise, deadline); + } finally { + clearTimeout(abort); + controller.abort(); + } +} + +export async function waitForSample( + sample: (deadline: number) => Promise, + retainAndAccept: (value: T) => boolean, + timeoutMs: number, + pollMs = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let value: T | undefined; + try { value = await withDeadline(() => sample(deadline), deadline); } catch {} + if (value !== undefined && retainAndAccept(value)) return; + await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.min(pollMs, Math.max(0, deadline - Date.now())))); + } + throw new Error("Bounded browser stage timed out"); +} + +export async function finalizeGate( + report: FinalizableReport, + gateSucceeded: boolean, + cleanup: () => Promise, +): Promise { + let result: CleanupResult; + try { + result = await cleanup(); + } catch { + result = { browserExited: false, nativeExited: false, serverClosed: false, + portsClosed: false, profileRemoved: false }; + } + const complete = Object.values(result).every(Boolean); + report.stages.cleanup = { ...result, complete }; + if (gateSucceeded && complete) { + report.status = "passed"; + report.failedStage = null; + } else if (!complete) { + report.status = "failed"; + report.failedStage = "cleanup"; + } +} + +export function isExactGateProfile(profile: string, systemTemp: string): boolean { + const candidate = resolve(profile); + const temp = resolve(systemTemp); + const same = process.platform === "win32" + ? dirname(candidate).toLowerCase() === temp.toLowerCase() + : dirname(candidate) === temp; + return same && /^screener-native-one-viewer-[A-Za-z0-9_-]{6}$/.test(basename(candidate)); +} diff --git a/scripts/native-one-viewer-gate.ts b/scripts/native-one-viewer-gate.ts new file mode 100644 index 0000000..0535b50 --- /dev/null +++ b/scripts/native-one-viewer-gate.ts @@ -0,0 +1,977 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { createConnection, createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import WebSocket from "ws"; +import { createScreenerServer, type ScreenerServer } from "../src/server/app"; +import { loadConfig } from "../src/server/config"; +import { + RoomStore, + type ConnectedParticipant, + type ConnectParticipantInput, + type RoomStoreOptions, +} from "../src/server/room-store"; +import { + SenderStartLedger, + waitForSenderStart, + type SenderStartObservation, +} from "./native-one-viewer-ledger"; +import { + captureOneViewerIdentity, + fetchJsonBefore, + finalizeGate, + isExactGateProfile, + retainsFirstBridgeSend, + retainsOneViewerIdentity, + waitForSample, + withDeadline, + type CleanupResult, + type OneViewerIdentity, + type SenderIdentityEvidence, + type ViewerIdentityEvidence, +} from "./native-one-viewer-gate-core"; + +type Stage = "preflight" | "sender-start" | "node-host" | "viewer-signal" | "viewer-media" | "cleanup"; + +interface PageHandle { + sessionId: string; +} + +interface CdpResult { + result: { value?: T }; + exceptionDetails?: unknown; +} + +interface GateReport { + schemaVersion: 1; + status: "passed" | "failed"; + failedStage: Stage | null; + stages: Record | undefined>; + limitations: string[]; +} + +interface SenderSnapshot { + getDisplayMediaRequested: boolean; + getDisplayMediaResolved: boolean; + ready: number; + configAccepted: number; + encoderInstances: number; + encoderOutputs: number; + encoderErrors: number; + fatalEvents: number; + bridgeGeneration: number; + binarySendAttempts: number; + binarySendSucceeded: number; + binarySendFailed: number; + activeViewers: number; + maxPeers: number; + config: { + codec: string | null; + width: number | null; + height: number | null; + fps: number | null; + bitrate: number | null; + encoderInstances: number | null; + } | null; + media: { + framesWritten: number; + sourceRtpPacketsWritten: number; + sourceRtpBytesWritten: number; + } | null; + peers: Array<{ + slot: number; + edgeGeneration: number; + connectionState: string; + iceConnectionState: string; + route: string; + packetsSent: number; + bytesSent: number; + }>; +} + +interface ViewerSnapshot { + signaling: { + authenticateSent: number; + authenticatedReceived: number; + offersReceived: number; + answersSent: number; + candidatesReceived: number; + candidatesSent: number; + }; + identity: ViewerIdentityEvidence; + connectionState: string; + iceConnectionState: string; + packetsReceived: number; + bytesReceived: number; + framesDecoded: number; + videoReadyState: number; + videoWidth: number; + videoHeight: number; + videoCurrentTime: number; + renderedFrames: number; +} + +class CdpConnection { + private nextId = 1; + private readonly pending = new Map void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + }>(); + + private constructor(private readonly socket: WebSocket) { + socket.on("message", (raw) => this.onMessage(raw.toString())); + socket.on("close", () => this.closePending()); + socket.on("error", () => this.closePending()); + } + + static async connect(url: string, deadline: number): Promise { + const socket = new WebSocket(url, { maxPayload: 16 * 1024 * 1024 }); + try { + await withDeadline(() => new Promise((resolveOpen, rejectOpen) => { + socket.once("open", resolveOpen); + socket.once("error", rejectOpen); + }), deadline); + } catch (error) { + socket.terminate(); + throw error; + } + return new CdpConnection(socket); + } + + call(method: string, params: Record, sessionId: string | undefined, deadline: number): Promise { + const id = this.nextId++; + return new Promise((resolveCall, rejectCall) => { + const remaining = Math.max(0, deadline - Date.now()); + const timeout = setTimeout(() => { + this.pending.delete(id); + rejectCall(new Error("CDP command timed out")); + }, remaining); + this.pending.set(id, { + resolve: (value) => { clearTimeout(timeout); resolveCall(value as T); }, + reject: (error) => { clearTimeout(timeout); rejectCall(error); }, + timeout, + }); + this.socket.send(JSON.stringify({ id, method, params, sessionId })); + }); + } + + close(): void { + this.socket.close(); + } + + private onMessage(raw: string): void { + let message: { id?: number; result?: unknown; error?: unknown }; + try { + message = JSON.parse(raw) as typeof message; + } catch { + return; + } + if (message.id === undefined) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error("CDP command failed")); + } else { + pending.resolve(message.result ?? {}); + } + } + + private closePending(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("CDP connection closed")); + } + this.pending.clear(); + } +} + +const root = resolve(import.meta.dirname, ".."); + +class ObservedRoomStore extends RoomStore { + constructor(options: RoomStoreOptions, private readonly onHostAuthenticated: () => void) { + super(options); + } + + override connectParticipant(input: ConnectParticipantInput): ConnectedParticipant { + const participant = super.connectParticipant(input); + if (input.role === "host") this.onHostAuthenticated(); + return participant; + } +} + +function observeNodeStages(server: ScreenerServer, ledger: SenderStartLedger): void { + server.httpServer.prependListener("request", (request, response) => { + const path = new URL(request.url ?? "/", "http://gate.invalid").pathname; + if (request.method === "POST" && path === "/api/host-admission") { + response.once("finish", () => { + if (response.statusCode === 200) ledger.record({ hostAdmissionAccepted: true }); + }); + } else if (request.method === "POST" && path === "/api/rooms") { + response.once("finish", () => { + if (response.statusCode === 201) ledger.record({ roomCreated: true }); + }); + } + }); +} + +function senderStartObservation(snapshot: SenderSnapshot): Partial { + return { + getDisplayMediaRequested: snapshot.getDisplayMediaRequested, + getDisplayMediaResolved: snapshot.getDisplayMediaResolved, + bridgeConnected: snapshot.ready > 0, + fixedHighConfigured: snapshot.configAccepted > 0 && + snapshot.config?.codec === "vp8" && snapshot.config.width === 1280 && + snapshot.config.height === 720 && snapshot.config.fps === 30 && + snapshot.config.bitrate === 3_000_000 && snapshot.config.encoderInstances === 1, + firstEncodedChunk: snapshot.encoderOutputs > 0, + bridgeGeneration: snapshot.bridgeGeneration, + binarySendAttempts: snapshot.binarySendAttempts, + binarySendSucceeded: snapshot.binarySendSucceeded, + binarySendFailed: snapshot.binarySendFailed, + encoderInstances: snapshot.encoderInstances, + framesWritten: snapshot.media?.framesWritten ?? 0, + sourceRtpPacketsWritten: snapshot.media?.sourceRtpPacketsWritten ?? 0, + sourceRtpBytesWritten: snapshot.media?.sourceRtpBytesWritten ?? 0, + }; +} + +function requiredEnvironmentPath(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function appendLedgerRecord(path: string, record: object): void { + const descriptor = openSync(path, "a"); + try { + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" }); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +async function main(): Promise { + const chromePath = requiredEnvironmentPath("SCREENER_NATIVE_GATE_CHROME"); + const goPath = process.env.SCREENER_NATIVE_GATE_GO?.trim() || "go"; + const accessKey = randomBytes(9).toString("base64url"); + const report: GateReport = { + schemaVersion: 1, + status: "failed", + failedStage: "preflight", + stages: {}, + limitations: [ + "The source is a real animated Chrome tab captured through getDisplayMedia, not a game workload.", + "This run proves one WebCodecs object, not one physical or hardware encoder.", + "The loopback run does not prove public STUN, restrictive networks, TURN, audio, packaging, or endurance.", + ], + }; + let server: ScreenerServer | null = null; + let native: ChildProcessWithoutNullStreams | null = null; + let chrome: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let profile: string | null = null; + let ledgerPath: string | null = null; + let appPort: number | null = null; + let debugPort: number | null = null; + let nativePort: number | null = null; + let gateSucceeded = false; + const senderLedger = new SenderStartLedger((record) => { + if (!ledgerPath) return; + appendLedgerRecord(ledgerPath, record); + }); + + try { + appPort = await reservePort(); + debugPort = await reservePort(); + profile = await mkdtemp(join(tmpdir(), "screener-native-one-viewer-")); + ledgerPath = join(profile, "sender-start-ledger.jsonl"); + writeFileSync(ledgerPath, "", { encoding: "utf8", flag: "wx" }); + const baseUrl = `http://127.0.0.1:${appPort}`; + const config = loadConfig({ + NODE_ENV: "development", + PORT: String(appPort), + LISTEN_HOST: "127.0.0.1", + PUBLIC_BASE_URL: baseUrl, + ALLOWED_ORIGINS: baseUrl, + HOST_ADMISSION_PASSWORD: accessKey, + ROOM_DATABASE_PATH: "", + PEER_ASSISTED_MEDIA: "false", + PEER_ASSISTED_ROOM_IDS: "", + STUN_URLS: "", + }); + const roomStore = new ObservedRoomStore({ + ttlMs: config.roomTtlMs, + maxRooms: config.maxRooms, + maxViewersPerRoom: config.maxViewersPerRoom, + }, () => senderLedger.record({ hostWssAuthenticated: true })); + server = await createScreenerServer({ config, roomStore }); + observeNodeStages(server, senderLedger); + await server.listen(appPort, "127.0.0.1"); + + native = spawn(goPath, ["run", "./cmd/screener-one-viewer-gate"], { + cwd: join(root, "native", "sender"), + stdio: "pipe", + windowsHide: true, + }); + native.stderr.resume(); + const launchUrl = await readLaunchUrl(native); + nativePort = Number(new URL(launchUrl).port) || null; + chrome = spawn(chromePath, [ + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--autoplay-policy=no-user-gesture-required", + "--auto-select-tab-capture-source-by-title=Native Gate Source", + "--window-size=1280,900", + "about:blank", + ], { stdio: "pipe", windowsHide: true }); + chrome.stdout.resume(); + chrome.stderr.resume(); + const version = await waitForVersion(debugPort, chrome); + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000); + report.stages.preflight = { + serverInMemory: config.roomDatabasePath === undefined, + peerAssistedDisabled: !config.peerAssistedMedia, + oneViewerOnly: true, + chromeMajor: Number(version.Browser.match(/Chrome\/(\d+)/)?.[1] ?? 0), + }; + report.failedStage = "sender-start"; + + await createPage(cdp, animatedSourceUrl()); + const senderPage = await createPage(cdp, launchUrl, senderProbe()); + await evaluate(cdp, senderPage, `(() => { + document.querySelector('#server-url').value = ${JSON.stringify(baseUrl)}; + document.querySelector('#password').value = ${JSON.stringify(accessKey)}; + document.querySelector('#start').click(); + })()`, Date.now() + 5_000); + await waitForSenderStart( + async (deadline) => senderStartObservation(await senderSnapshot(cdp!, senderPage, deadline)), + senderLedger, + { timeoutMs: 20_000 }, + ); + const senderReady = await senderSnapshot(cdp, senderPage, Date.now() + 3_000); + const senderStartChecks = { + oneEncoderObject: senderReady.encoderInstances === 1, + currentBridge: retainsFirstBridgeSend(senderReady), + noCriticalErrors: senderReady.encoderErrors === 0 && senderReady.fatalEvents === 0, + }; + report.stages["sender-start"] = { ...senderLedger.snapshot(), ...senderStartChecks }; + requireChecks(senderStartChecks); + report.failedStage = "node-host"; + const inviteUrl = await evaluate(cdp, senderPage, + `document.querySelector('#invite-link').href`, Date.now() + 3_000); + const invite = new URL(inviteUrl); + const roomId = invite.pathname.match(/^\/r\/([1-9][0-9]{0,11})$/)?.[1] ?? ""; + const viewerGrant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? ""; + const nodeHost = { + privateGrantBoundToRoom: new RegExp(`^g1\\.${roomId}\\.[1-9][0-9]{0,12}\\.[A-Za-z0-9_-]{43}$`).test(viewerGrant), + provisionalRandomRoom: roomId !== "1", + }; + report.stages["node-host"] = nodeHost; + requireChecks(nodeHost); + + report.failedStage = "viewer-signal"; + const viewerPage = await createPage(cdp, inviteUrl, viewerProbe()); + let identity: OneViewerIdentity | null = null; + await waitForSample(async (deadline) => ({ + viewer: await viewerSnapshot(cdp!, viewerPage, deadline), + sender: await senderSnapshot(cdp!, senderPage, deadline), + }), ({ viewer, sender }) => { + identity = captureOneViewerIdentity(viewer.identity, senderIdentity(sender)); + const checks = { + viewerAuthenticated: viewer.signaling.authenticateSent === 1 && + viewer.signaling.authenticatedReceived === 1, + peerJoined: sender.activeViewers === 1 && roomStore.getConnectedViewers(roomId).length === 1, + offerAnswer: viewer.signaling.offersReceived >= 1 && viewer.signaling.answersSent >= 1, + iceExchange: viewer.signaling.candidatesReceived >= 1 && viewer.signaling.candidatesSent >= 1, + connected: viewer.connectionState === "connected" && + ["connected", "completed"].includes(viewer.iceConnectionState), + pionConnected: sender.peers.length === 1 && sender.peers[0]?.connectionState === "connected" && + ["connected", "completed"].includes(sender.peers[0]?.iceConnectionState ?? ""), + sameGeneration: identity !== null, + sameBridge: retainsFirstBridgeSend(sender), + edgeBound: sender.maxPeers <= 1 && sender.peers.length === 1, + }; + report.stages["viewer-signal"] = { + ...checks, + offersReceived: viewer.signaling.offersReceived, + answersSent: viewer.signaling.answersSent, + candidatesReceived: viewer.signaling.candidatesReceived, + candidatesSent: viewer.signaling.candidatesSent, + socketOrdinal: identity?.socketOrdinal ?? 0, + authGeneration: identity?.authGeneration ?? 0, + connectionOrdinal: identity?.connectionOrdinal ?? 0, + pcOrdinal: identity?.pcOrdinal ?? 0, + trackOrdinal: identity?.trackOrdinal ?? 0, + pionSlot: identity?.pionSlot ?? -1, + pionEdgeGeneration: identity?.pionEdgeGeneration ?? 0, + }; + return Object.values(checks).every(Boolean); + }, 20_000); + + report.failedStage = "viewer-media"; + if (!identity) throw new Error("Viewer generation was not established"); + const mediaBaselineDeadline = Date.now() + 3_000; + const [beforeViewer, beforeSender] = await Promise.all([ + viewerSnapshot(cdp, viewerPage, mediaBaselineDeadline), + senderSnapshot(cdp, senderPage, mediaBaselineDeadline), + ]); + if (!retainsOneViewerIdentity(identity, beforeViewer.identity, senderIdentity(beforeSender))) { + throw new Error("Viewer generation changed before media sampling"); + } + await waitForSample(async (deadline) => ({ + viewer: await viewerSnapshot(cdp!, viewerPage, deadline), + sender: await senderSnapshot(cdp!, senderPage, deadline), + }), ({ viewer, sender }) => { + const checks = { + inboundPackets: viewer.packetsReceived > beforeViewer.packetsReceived, + inboundBytes: viewer.bytesReceived > beforeViewer.bytesReceived, + decodedFrames: viewer.framesDecoded > beforeViewer.framesDecoded, + videoReady: viewer.videoReadyState >= 2 && viewer.videoWidth > 0 && viewer.videoHeight > 0, + playbackClock: viewer.videoCurrentTime > beforeViewer.videoCurrentTime, + renderedFrames: viewer.renderedFrames > beforeViewer.renderedFrames, + pionOutbound: (sender.peers[0]?.packetsSent ?? 0) > (beforeSender.peers[0]?.packetsSent ?? 0) && + (sender.peers[0]?.bytesSent ?? 0) > (beforeSender.peers[0]?.bytesSent ?? 0), + sameGeneration: retainsOneViewerIdentity(identity!, viewer.identity, senderIdentity(sender)), + sameBridge: retainsFirstBridgeSend(sender), + oneEncoderObject: sender.encoderInstances === 1, + criticalErrors: sender.fatalEvents === 0 && sender.encoderErrors === 0, + }; + report.stages["viewer-media"] = { + ...checks, + packetDelta: viewer.packetsReceived - beforeViewer.packetsReceived, + byteDelta: viewer.bytesReceived - beforeViewer.bytesReceived, + decodedDelta: viewer.framesDecoded - beforeViewer.framesDecoded, + renderedDelta: viewer.renderedFrames - beforeViewer.renderedFrames, + pionPacketDelta: (sender.peers[0]?.packetsSent ?? 0) - (beforeSender.peers[0]?.packetsSent ?? 0), + pcOrdinal: viewer.identity.pcOrdinal, + pionSlot: sender.peers[0]?.slot ?? -1, + pionEdgeGeneration: sender.peers[0]?.edgeGeneration ?? 0, + route: safeRoute(sender.peers[0]?.route), + videoWidth: viewer.videoWidth, + videoHeight: viewer.videoHeight, + }; + return Object.values(checks).every(Boolean); + }, 10_000); + const finalSender = await senderSnapshot(cdp, senderPage, Date.now() + 3_000); + const finalChecks = { finalBridge: retainsFirstBridgeSend(finalSender) }; + report.stages["viewer-media"] = { ...report.stages["viewer-media"], ...finalChecks }; + requireChecks(finalChecks); + gateSucceeded = true; + } catch { + // The bounded report retains only the first stage and sanitized counters. + } finally { + report.stages["sender-start"] = { + ...report.stages["sender-start"], + ...senderLedger.snapshot(), + }; + await finalizeGate(report, gateSucceeded, () => cleanupRun({ + cdp, native, chrome, server, profile, + ports: [appPort, debugPort, nativePort].filter((port): port is number => port !== null), + })); + console.log(JSON.stringify(report, null, 2)); + if (report.status === "failed") process.exitCode = 1; + } +} + +export function senderProbe(): string { + return `(() => { + const state = { getDisplayMediaRequested: false, getDisplayMediaResolved: false, + ready: 0, configAccepted: 0, + encoderInstances: 0, encoderOutputs: 0, encoderErrors: 0, fatalEvents: 0, + bridgeGeneration: 0, binarySendAttempts: 0, binarySendSucceeded: 0, binarySendFailed: 0, + activeViewers: 0, maxPeers: 0, edgeGeneration: 0, activeEdgeSlot: null, + config: null, media: null, peers: [] }; + const finite = (value) => Number.isFinite(value) ? value : 0; + const mark = (key) => { state[key] = 1; }; + let activeBridgeSocket = null; + const nativeGetDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); + Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', { + configurable: true, + value: (...args) => { + state.getDisplayMediaRequested = true; + return nativeGetDisplayMedia(...args).then((stream) => { + state.getDisplayMediaResolved = true; + return stream; + }); + }, + }); + const NativeWebSocket = globalThis.WebSocket; + globalThis.WebSocket = class GateWebSocket extends NativeWebSocket { + constructor(...args) { + super(...args); + this.__gateMedia = new URL(String(args[0]), location.href).pathname === '/media'; + if (!this.__gateMedia) return; + state.bridgeGeneration = Math.min(2, state.bridgeGeneration + 1); + this.__gateBridgeGeneration = state.bridgeGeneration; + activeBridgeSocket = state.bridgeGeneration === 1 ? this : null; + this.addEventListener('message', (event) => { + if (typeof event.data !== 'string') return; + let message; try { message = JSON.parse(event.data); } catch { return; } + if (message.kind === 'ready') state.ready += 1; + if (message.kind === 'config-accepted') state.configAccepted += 1; + if (message.kind === 'fatal') state.fatalEvents += 1; + if (message.kind === 'ready' || message.kind === 'viewer-count') { + state.activeViewers = finite(message.activeViewers); + } + if (message.kind === 'diagnostics') { + state.media = message.media ? { + framesWritten: finite(message.media.framesWritten), + sourceRtpPacketsWritten: finite(message.media.sourceRtpPacketsWritten), + sourceRtpBytesWritten: finite(message.media.sourceRtpBytesWritten), + } : null; + const peers = Array.isArray(message.peers) ? message.peers.slice(0, 3) : []; + const nextSlot = peers.length === 1 ? finite(peers[0].slot) : null; + if (nextSlot !== null && nextSlot !== state.activeEdgeSlot) state.edgeGeneration += 1; + state.activeEdgeSlot = nextSlot; + state.peers = peers.map((peer) => ({ + slot: finite(peer.slot), edgeGeneration: state.edgeGeneration, + connectionState: String(peer.connectionState || 'unknown'), + iceConnectionState: String(peer.iceConnectionState || 'unknown'), + route: String(peer.route || 'unknown'), + packetsSent: finite(peer.packetsSent), bytesSent: finite(peer.bytesSent), + })); + state.maxPeers = Math.max(state.maxPeers, state.peers.length); + } + }); + } + send(value) { + if (this.__gateMedia && this === activeBridgeSocket && + this.__gateBridgeGeneration === 1 && value instanceof ArrayBuffer) { + mark('binarySendAttempts'); + try { + const result = super.send(value); + mark('binarySendSucceeded'); + return result; + } catch (error) { + mark('binarySendFailed'); + throw error; + } + } + if (this.__gateMedia) { + if (typeof value === 'string') { + let message; try { message = JSON.parse(value); } catch { message = null; } + if (message?.kind === 'config') state.config = { + codec: typeof message.codec === 'string' ? message.codec : null, + width: finite(message.width), height: finite(message.height), fps: finite(message.fps), + bitrate: finite(message.bitrate), encoderInstances: finite(message.encoderInstances), + }; + } + } + return super.send(value); + } + }; + if (typeof globalThis.VideoEncoder === 'function') { + const NativeVideoEncoder = globalThis.VideoEncoder; + globalThis.VideoEncoder = new Proxy(NativeVideoEncoder, { + construct(Target, args) { + const init = args[0] || {}; + const wrapped = { ...init, + output: (...values) => { state.encoderOutputs += 1; return init.output(...values); }, + error: (...values) => { state.encoderErrors += 1; return init.error(...values); }, + }; + const encoder = Reflect.construct(Target, [wrapped], Target); + state.encoderInstances += 1; + return encoder; + }, + get(Target, property, receiver) { + const value = Reflect.get(Target, property, receiver); + return typeof value === 'function' && property === 'isConfigSupported' ? value.bind(Target) : value; + }, + }); + } + Object.defineProperty(globalThis, '__NATIVE_ONE_VIEWER_GATE__', { + value: { snapshot: () => structuredClone(state) }, configurable: false, + }); + })();`; +} + +function viewerProbe(): string { + return `(() => { + const state = { signaling: { authenticateSent: 0, authenticatedReceived: 0, + offersReceived: 0, answersSent: 0, candidatesReceived: 0, candidatesSent: 0 }, + socketCount: 0, authenticateSocketOrdinal: 0, authSocketOrdinal: 0, authGeneration: 0, + connectionIds: [], activeConnectionId: null, consistent: true, pendingOffer: null, + pcs: [], boundPcOrdinal: 0, boundConnectionOrdinal: 0, boundSocketOrdinal: 0, + boundAuthGeneration: 0, trackCount: 0, trackOrdinal: 0, trackPcOrdinal: 0, + renderedFrames: 0, observedVideo: null }; + const connectionOrdinal = (value) => { + let index = state.connectionIds.indexOf(value); + if (index < 0) { state.connectionIds.push(value); index = state.connectionIds.length - 1; } + return index + 1; + }; + const inspect = (data, direction, socketOrdinal) => { + if (typeof data !== 'string') return; + let message; try { message = JSON.parse(data); } catch { return; } + if (direction === 'out' && message.type === 'authenticate') { + state.signaling.authenticateSent += 1; state.authenticateSocketOrdinal = socketOrdinal; return; + } + if (direction === 'in' && message.type === 'authenticated') { + state.signaling.authenticatedReceived += 1; state.authGeneration += 1; + state.authSocketOrdinal = socketOrdinal; + if (state.authenticateSocketOrdinal !== socketOrdinal) state.consistent = false; + return; + } + if (message.type !== 'signal' || !message.payload) return; + const rawConnectionId = message.payload.connectionId; + if (typeof rawConnectionId !== 'string' || state.authSocketOrdinal !== socketOrdinal) { + state.consistent = false; return; + } + const ordinal = connectionOrdinal(rawConnectionId); + if (state.activeConnectionId === null && direction === 'in' && + message.payload.kind === 'description' && message.payload.description?.type === 'offer') { + state.activeConnectionId = rawConnectionId; + } + if (state.activeConnectionId !== rawConnectionId) state.consistent = false; + if (direction === 'in' && message.payload.kind === 'description' && message.payload.description?.type === 'offer') { + state.signaling.offersReceived += 1; + state.pendingOffer = { rawConnectionId, ordinal, socketOrdinal, authGeneration: state.authGeneration }; + } + if (direction === 'out' && message.payload.kind === 'description' && message.payload.description?.type === 'answer') { + state.signaling.answersSent += 1; + if (state.boundConnectionOrdinal !== ordinal || state.boundSocketOrdinal !== socketOrdinal || + state.boundAuthGeneration !== state.authGeneration) state.consistent = false; + } + if (direction === 'in' && message.payload.kind === 'candidate') state.signaling.candidatesReceived += 1; + if (direction === 'out' && message.payload.kind === 'candidate') state.signaling.candidatesSent += 1; + }; + const NativeWebSocket = globalThis.WebSocket; + globalThis.WebSocket = class GateWebSocket extends NativeWebSocket { + constructor(...args) { + super(...args); + this.__gateSignal = new URL(String(args[0]), location.href).pathname === '/signal'; + if (this.__gateSignal) { + this.__gateSocketOrdinal = ++state.socketCount; + this.addEventListener('message', (event) => inspect(event.data, 'in', this.__gateSocketOrdinal)); + } + } + send(value) { + if (this.__gateSignal) inspect(value, 'out', this.__gateSocketOrdinal); + return super.send(value); + } + }; + const NativePeerConnection = globalThis.RTCPeerConnection; + globalThis.RTCPeerConnection = class GatePeerConnection extends NativePeerConnection { + constructor(...args) { + super(...args); + state.pcs.push(this); + this.__gatePcOrdinal = state.pcs.length; + this.addEventListener('track', (event) => { + if (event.track?.kind !== 'video') return; + state.trackCount += 1; state.trackOrdinal = state.trackCount; + state.trackPcOrdinal = this.__gatePcOrdinal; + }); + } + async setRemoteDescription(description) { + const result = await super.setRemoteDescription(description); + if (description?.type === 'offer') { + const offer = state.pendingOffer; + if (!offer || offer.rawConnectionId !== state.activeConnectionId) state.consistent = false; + else { + state.boundPcOrdinal = this.__gatePcOrdinal; + state.boundConnectionOrdinal = offer.ordinal; + state.boundSocketOrdinal = offer.socketOrdinal; + state.boundAuthGeneration = offer.authGeneration; + } + } + return result; + } + }; + async function snapshot() { + const video = document.querySelector('.remote-stage video'); + if (video && state.observedVideo !== video && typeof video.requestVideoFrameCallback === 'function') { + state.observedVideo = video; + const frame = () => { state.renderedFrames += 1; video.requestVideoFrameCallback(frame); }; + video.requestVideoFrameCallback(frame); + } + const pc = state.pcs[state.boundPcOrdinal - 1]; + let packetsReceived = 0, bytesReceived = 0, framesDecoded = 0; + if (pc) { + const stats = await pc.getStats(); + stats.forEach((entry) => { + if (entry.type === 'inbound-rtp' && entry.kind === 'video' && entry.isRemote !== true) { + packetsReceived += finite(entry.packetsReceived); + bytesReceived += finite(entry.bytesReceived); + framesDecoded += finite(entry.framesDecoded); + } + }); + } + const activeOrdinal = state.activeConnectionId === null ? 0 : + state.connectionIds.indexOf(state.activeConnectionId) + 1; + const identity = { socketCount: state.socketCount, socketOrdinal: state.authSocketOrdinal, + authGeneration: state.authGeneration, connectionCount: state.connectionIds.length, + connectionOrdinal: activeOrdinal, pcCount: state.pcs.length, pcOrdinal: state.boundPcOrdinal, + trackCount: state.trackCount, trackOrdinal: state.trackOrdinal, + consistent: state.consistent && state.boundConnectionOrdinal === activeOrdinal && + state.boundSocketOrdinal === state.authSocketOrdinal && + state.boundAuthGeneration === state.authGeneration && state.trackPcOrdinal === state.boundPcOrdinal }; + return { signaling: structuredClone(state.signaling), identity, + connectionState: pc?.connectionState || 'none', + iceConnectionState: pc?.iceConnectionState || 'none', packetsReceived, bytesReceived, framesDecoded, + videoReadyState: finite(video?.readyState), videoWidth: finite(video?.videoWidth), + videoHeight: finite(video?.videoHeight), videoCurrentTime: finite(video?.currentTime), + renderedFrames: state.renderedFrames }; + } + const finite = (value) => Number.isFinite(value) ? value : 0; + Object.defineProperty(globalThis, '__NATIVE_VIEWER_GATE__', { + value: { snapshot }, configurable: false, + }); + })();`; +} + +async function createPage(cdp: CdpConnection, url: string, probe?: string): Promise { + const deadline = Date.now() + 15_000; + const created = await cdp.call<{ targetId: string }>("Target.createTarget", { url: "about:blank", background: false }, undefined, deadline); + const attached = await cdp.call<{ sessionId: string }>("Target.attachToTarget", { targetId: created.targetId, flatten: true }, undefined, deadline); + const page = { sessionId: attached.sessionId }; + await Promise.all([ + cdp.call("Page.enable", {}, page.sessionId, deadline), + cdp.call("Runtime.enable", {}, page.sessionId, deadline), + ]); + if (probe) await cdp.call("Page.addScriptToEvaluateOnNewDocument", { source: probe }, page.sessionId, deadline); + await cdp.call("Page.navigate", { url }, page.sessionId, deadline); + await waitForSample( + (sampleDeadline) => evaluate(cdp, page, "document.readyState === 'complete'", sampleDeadline), + Boolean, + Math.max(1, deadline - Date.now()), + ); + return page; +} + +async function evaluate(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise { + const value = await cdp.call>("Runtime.evaluate", { + expression, awaitPromise: true, returnByValue: true, userGesture: true, + }, page.sessionId, deadline); + if (value.exceptionDetails) throw new Error("Browser evaluation failed"); + return value.result.value as T; +} + +function senderSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise { + return evaluate(cdp, page, "globalThis.__NATIVE_ONE_VIEWER_GATE__.snapshot()", deadline); +} + +function viewerSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise { + return evaluate(cdp, page, "globalThis.__NATIVE_VIEWER_GATE__.snapshot()", deadline); +} + +function senderIdentity(sender: SenderSnapshot): SenderIdentityEvidence { + const peer = sender.peers[0]; + return { peerCount: sender.peers.length, maxPeers: sender.maxPeers, + slot: peer?.slot ?? -1, edgeGeneration: peer?.edgeGeneration ?? 0 }; +} + +function requireChecks(checks: Record): void { + if (!Object.values(checks).every(Boolean)) throw new Error("Gate check failed"); +} + +function safeRoute(value: string | undefined): string { + return new Set(["direct-udp", "direct-tcp", "turn-udp", "turn-tcp", "turn-tls", "turn-unknown"]) + .has(value ?? "") ? value! : "unknown"; +} + +async function reservePort(): Promise { + const server = createServer(); + return new Promise((resolvePort, rejectPort) => { + server.once("error", rejectPort); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") return rejectPort(new Error("Port reservation failed")); + server.close((error) => error ? rejectPort(error) : resolvePort(address.port)); + }); + }); +} + +async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise { + let buffer = ""; + return new Promise((resolveUrl, rejectUrl) => { + const timeout = setTimeout(() => rejectUrl(new Error("Native runner startup timed out")), 20_000); + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const end = buffer.indexOf("\n"); + if (end < 0) return; + clearTimeout(timeout); + try { + const parsed = JSON.parse(buffer.slice(0, end)) as { launchUrl?: unknown }; + if (typeof parsed.launchUrl !== "string") throw new Error(); + resolveUrl(parsed.launchUrl); + } catch { + rejectUrl(new Error("Native runner handoff was invalid")); + } + }); + child.once("exit", () => { + clearTimeout(timeout); + rejectUrl(new Error("Native runner exited before handoff")); + }); + }); +} + +async function waitForVersion(port: number, child: ChildProcessWithoutNullStreams): Promise<{ Browser: string; webSocketDebuggerUrl: string }> { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline && child.exitCode === null) { + try { + return await fetchJsonBefore<{ Browser: string; webSocketDebuggerUrl: string }>( + `http://127.0.0.1:${port}/json/version`, deadline, + ); + } catch {} + await delay(Math.min(100, Math.max(0, deadline - Date.now()))); + } + throw new Error("Chrome startup timed out"); +} + +interface CleanupResources { + cdp: CdpConnection | null; + native: ChildProcessWithoutNullStreams | null; + chrome: ChildProcessWithoutNullStreams | null; + server: ScreenerServer | null; + profile: string | null; + ports: number[]; +} + +async function cleanupRun(resources: CleanupResources): Promise { + if (resources.cdp) { + try { await resources.cdp.call("Browser.close", {}, undefined, Date.now() + 2_000); } catch {} + resources.cdp.close(); + } + const browserExited = resources.chrome ? await stopProcessTree(resources.chrome) : true; + if (resources.native && processRunning(resources.native)) { + try { resources.native.stdin.write("\n"); } catch {} + } + const nativeExited = resources.native ? await stopProcessTree(resources.native) : true; + let serverClosed = true; + if (resources.server) { + try { await withDeadline(() => resources.server!.close(), Date.now() + 5_000); } catch { serverClosed = false; } + } + const portsClosed = await waitForPortsClosed([...new Set(resources.ports)], 5_000); + const profileRemoved = resources.profile ? await removeGateProfile(resources.profile) : true; + return { browserExited, nativeExited, serverClosed, portsClosed, profileRemoved }; +} + +function processRunning(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode === null && child.signalCode === null; +} + +async function stopProcessTree(child: ChildProcessWithoutNullStreams): Promise { + if (!processRunning(child)) return true; + if (await waitForExit(child, 2_000)) return true; + if (process.platform !== "win32" || child.pid === undefined) return false; + await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000); + return waitForExit(child, 3_000); +} + +async function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + if (!processRunning(child)) return true; + return new Promise((resolveExit) => { + const onExit = () => { clearTimeout(timeout); resolveExit(true); }; + const timeout = setTimeout(() => { + child.off("exit", onExit); + resolveExit(!processRunning(child)); + }, timeoutMs); + child.once("exit", onExit); + }); +} + +async function runBounded(command: string, args: string[], timeoutMs: number, + environment: NodeJS.ProcessEnv = process.env): Promise { + const child = spawn(command, args, { stdio: "pipe", windowsHide: true, env: environment }); + child.stdout.resume(); + child.stderr.resume(); + try { + const code = await withDeadline(() => new Promise((resolveExit, rejectExit) => { + child.once("error", rejectExit); + child.once("exit", resolveExit); + }), Date.now() + timeoutMs); + return code === 0; + } catch { + child.kill(); + return false; + } +} + +async function waitForPortsClosed(ports: number[], timeoutMs: number): Promise { + if (ports.length === 0) return true; + try { + await waitForSample( + (deadline) => Promise.all(ports.map((port) => portClosed(port, deadline))), + (closed) => closed.every(Boolean), + timeoutMs, + ); + return true; + } catch { + return false; + } +} + +function portClosed(port: number, deadline: number): Promise { + return withDeadline(() => new Promise((resolveClosed) => { + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (closed: boolean) => { socket.destroy(); resolveClosed(closed); }; + socket.once("connect", () => finish(false)); + socket.once("error", () => finish(true)); + }), deadline); +} + +export const profileCleanupScript = String.raw` +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE) +$temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar) +$parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar) +$name = [IO.Path]::GetFileName($root) +if ($parent -ine $temp -or $name -notmatch '^screener-native-one-viewer-[A-Za-z0-9_-]{6}$') { exit 31 } +$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) +if ($rootEntries.Count -eq 0) { exit 0 } +if ($rootEntries.Count -ne 1) { exit 34 } +$root = $rootEntries[0] +$rootAttributes = [IO.File]::GetAttributes($root) +if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } +if (($rootAttributes -band [IO.FileAttributes]::Directory) -eq 0) { exit 34 } +$pending = [Collections.Generic.Stack[string]]::new() +$pending.Push($root) +while ($pending.Count -gt 0) { + $directory = $pending.Pop() + $attributes = [IO.File]::GetAttributes($directory) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($directory)) { + $attributes = [IO.File]::GetAttributes($entry) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 } + if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { $pending.Push($entry) } + } +} +Remove-Item -LiteralPath $root -Recurse -Force +$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) +if ($rootEntries.Count -ne 0) { exit 33 } +`; + +async function removeGateProfile(profile: string): Promise { + if (process.platform !== "win32" || !isExactGateProfile(profile, tmpdir())) return false; + return runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", profileCleanupScript], + 10_000, { ...process.env, SCREENER_GATE_PROFILE_TO_REMOVE: profile }); +} + +function animatedSourceUrl(): string { + const html = `Native Gate Source +