HydraIssues

Host kiosk stream inside the same Qt process — eliminate subprocess hopping
open feature Project: hydra-experiencenet Reporter: 22 Apr 2026 19:22

Description

Context

A single tile-tap on the Rupelmonde kiosk currently spawns three separate Qt processes and at least as many window transitions:

  1. HydraExperienceNet kiosk — shows the experience grid.
  2. HydraExperienceNet pair --headless <host> — pairs with Sunshine (v6.1.22+ has --headless so no window flashes here now, thanks to Phase 1).
  3. HydraExperienceNet stream <host> <app> — loads the CliStartStreamSegue then StreamSegue then stream; has its own ApplicationWindow and its own fullscreen Space.

Each one produces a visible transition. The kiosk window is hidden via hydraheadflatscreen's /api/v1/window/hide during streaming and shown again via /api/v1/window/show after exit. Process lifecycle, Space transitions, pair subprocess, stream subprocess — a lot of state to manage for what should be a single UX of grid → loading → stream → grid.

Phase 1 (issue #99 / v6.1.22) removed the pair window flash by making the pair subcommand headless. That leaves two processes and two window transitions per launch.

Goal

Run the whole kiosk flow in a single Qt process with a single ApplicationWindow. Transitions happen as StackView pushes/pops inside that one window.

kiosk process (and only process): grid → loading → stream → grid

Technical approach

Moonlight-Qt already has all the primitives as QObjects usable from QML without subprocesses:

  • ComputerManager / NvComputer — handles pairing via existing APIs. Already used by the pair subcommand; can be instantiated and driven inside the kiosk process.
  • Session (in streaming/session.{h,cpp}) — regular QObject. StreamSegue.qml already takes a Session property and drives the whole stream lifecycle.

Changes in hydra-experiencenet:

  1. In KioskView.qml's startStream(experienceName): instead of POSTing to the agent's /api/v1/stream/start (which spawns a stream subprocess), construct a Session in C++ / expose one to QML with the right NvComputer + NvApp, push StreamSegue.qml onto the StackView (which already exists in the kiosk ApplicationWindow), pass it the Session. The stream renders inside the kiosk's own SDL/Metal surface.
  2. Pairing runs in-process before the Session is created — call into ComputerManager directly; no subprocess invocation.
  3. Exit overlay's triggerExitFromMenu calls Session::setShouldExit(false) (already does); StreamSegue's existing sessionFinished handler pops back to the grid with stackView.pop().

Changes in hydraheadflatscreen:

  • startMoonlightStream, hideKioskWindow, showKioskWindow, waitForStreamExit all become unused. Delete.
  • pairWithSunshine becomes unused (pair happens in-process on the Qt side). Delete.
  • LocalAPI.startStream — optionally keep as a thin RPC the kiosk can call back into for auto-update / config refresh, but the actual stream launch moves into the kiosk.
  • Agent's role shrinks to: auto-update the kiosk binary, fetch head config, maintain WireGuard tunnel. No subprocess spawning, no window hide/show dance.

Benefits

  • Zero process switches between grid and stream.
  • Zero window flashes — everything in one NSWindow / QQuickWindow.
  • Simpler state — no pair subprocess, no stream subprocess, no hide/show RPC, no waitForStreamExit goroutine.
  • No Space transition between kiosk and stream — same Qt window just switches which QML view is on the StackView.

Tradeoffs / risks

  • Moderate refactor: ~200-300 LOC net (mostly deletion from the agent, moderate addition in KioskView.qml / supporting C++ to construct Session + NvComputer inside the kiosk process).
  • Coupling: the kiosk process now depends directly on the Moonlight streaming pipeline (ComputerManager, Session, decoder init). Currently it only depends on its own grid UI plus an HTTP call to the agent.
  • Any crash in the streaming pipeline now also takes down the kiosk. Today a stream subprocess crash is contained. Need to ensure the streaming code is robust or add a supervisor that can re-launch the kiosk process on crash.
  • Debugging: stream logs today land in /tmp/Moonlight-<pid>.log per stream subprocess. Once merged into kiosk, they go to whatever log Qt uses for the kiosk (likely /tmp/Moonlight-*.log still, but only one rolling file per kiosk lifetime).

Verification plan

  1. Grid → tile tap → stream renders. Single NSWindow throughout (verify with osascript -e 'tell application "System Events" to count windows of process "HydraExperienceNet"' returning 1 at each step, plus the always-on-top exit overlay Tool window = 2).
  2. Tap exit overlay → stream disconnects cleanly → grid reappears in the same window with no flash.
  3. 5-minute stability test on peppy-dumpling-32 (no process respawns, no window flickers).

Scope

  • Does NOT include: rewriting the streaming pipeline. We reuse Moonlight-Qt's existing Session + ComputerManager verbatim.
  • Does NOT include: changing the exit overlay — that stays as its own QQuickWindow with NSWindowCollectionBehaviorCanJoinAllSpaces.
  • Blocked by: nothing. Can be started after Phase 1 is field-verified.

Comments (1)

cederik 22 Apr 2026 20:13

Phase 2 status update after 2026-04-22 evening client-side hardening

Phase 1 (v6.1.22 / v2.0.29) shipped: pair subcommand is now headless. That removes one of the three subprocess windows.

The stream subprocess window is the remaining transition-visible one. We layered several mitigations tonight that produce a polished-but-not-perfect UX; each of them becomes obsolete once Phase 2 lands:

  • v6.1.23 — stream SDL window now gets SDL_WINDOW_FULLSCREEN_DESKTOP as a creation flag instead of a post-create SDL_SetWindowFullscreen call, so no brief windowed flash.
  • v6.1.24 — kiosk app's /api/v1/screenshot endpoint and its Screen Recording TCC request removed. Screenshots are done externally via Terminal's TCC. Phase 2 keeps this deletion.
  • v6.1.25 — exit overlay briefly expands to fullscreen with a black "Quitting experience" veil to cover the macOS Space handoff between stream subprocess closing and kiosk regaining its Space. Safety timer collapses it back after 1.5 s. Works, still imperfect: kiosk briefly flashes its streaming=true state ("Starting castle viewer") after exit before the poll flips it to idle.
  • v6.1.20 — KioskView.pollStreamStatus recognises agent's "idle" status so the grid unsticks after exit.
  • v6.1.20 — handleWindowShow re-enters fullscreen for non-Tool windows because macOS' hide() on a fullscreen NSWindow drops it out of its Space.
  • v2.0.28 — agent stream args use --display-mode borderless (not windowed) to avoid window chrome in the stream subprocess.

All of these mitigations exist solely because the kiosk grid, pairing, and stream live in separate OS processes today. Phase 2 collapses them into one process with one Qt window, which means:

  • No Space transition → no need for the veil (v6.1.25).
  • No SDL_Window creation by a separate subprocess → no creation-flag timing mitigations (v6.1.23).
  • No kiosk window hide / show RPC → the handleWindowHide/Show endpoints, the agent's goroutine, and the fullscreen-re-entry guard (v6.1.20) all go away.
  • KioskView's polling loop talking to the agent's /api/v1/stream/status can be replaced by a direct Qt signal/slot on the in-process Session — no poll interval, no race on state transitions.
  • The branded Loading experience screen just becomes a StackView push — no new Qt process spawn.

Updated scope for Phase 2

In addition to what's described in the original ticket:

  • Delete hideKioskWindow / showKioskWindow from agent and handleWindowHide / handleWindowShow from kiosk app.
  • Delete LocalAPI.waitForStreamExit goroutine from agent.
  • Delete the streaming polling loop in KioskView.qml; connect directly to Session signals.
  • Remove the quitting veil path in StreamOverlay.qml (the Space transition it covers will not exist).
  • Keep the --absolute-mouse and --display-mode borderless defaults in moonlightStreamArgs equivalents that end up in the in-process Session config.
  • Keep the NSWindowCollectionBehaviorCanJoinAllSpaces on the overlay — only necessary if the main kiosk window still enters a macOS fullscreen Space, which it will continue to. Verify the overlay continues to float above the stream rendering surface once the stream is in the same window/Space.

Risk notes unearthed tonight

  • NSWindow setCollectionBehavior: throws on invalid flag combos on macOS 26. v6.1.18's CanJoinAllSpaces | FullScreenAuxiliary | Stationary aborted with -[NSWindow _validateCollectionBehavior:]. Phase 2 must avoid the same pitfall — stick to CanJoinAllSpaces alone or validated combos. Wrap in @try/@catch as v6.1.19 does.
  • Qt.Tool-flagged windows become NSPanels on macOS with different Space behaviour than regular NSWindows. The exit overlay relies on this deliberately. Any rework should keep Qt.Tool.
  • Qt flags set at ApplicationWindow declaration time are honoured; flags set from QML at runtime do not recreate the NSWindow styleMask on macOS (the kiosk's title bar stayed visible in v6.1.15 until we moved Qt.FramelessWindowHint to the declaration-level binding via the kioskMode context property).