HydraIssues

[SUPERSEDED by #507] Single-app kiosk: in-process streaming, Linux first (design)
closed improvement Project: hydraheadflatscreen Reporter: claude 19 Aug 2026 12:25

Description

Design plan for converging the flatscreen head on a single-app architecture. The kiosk process hosts the stream in-process. The exit overlay lives in the same process. Linux (omarchy) ships first. macOS production is never at risk. This issue is the design record. It continues #111 (window consolidation Phase 2) with a concrete, fleet-safe plan. Related: #493 (omarchy port epic), #495 (subprocess pairing races, solved on Linux by native pairing), #498 (agent version in help dialog), #500 (AppImage libva).

1. Current architecture (verified in code)

Two processes run on a flatscreen head:

  • The Go agent (hydraheadflatscreen, main branch) serves 127.0.0.1:9740 (pkg/client/localapi.go). A tile tap in KioskView.qml calls POST /api/v1/stream/start. The agent runs body discovery (discovery.go), native Sunshine pairing (pairing.go, gamestream_pair.go, moonlight_cert_linux.go), then spawns a SEPARATE process: hydra-experiencenet stream <host> <app> with args from pkg/client/moonlight.go moonlightStreamArgs. That args function is layer 1 of the three-layer orientation contract (see the repo CLAUDE.md).
  • The Qt app (hydra-experiencenet, master branch) serves 127.0.0.1:9741 (app/api/localserver.cpp) with /probe, /window/hide, /window/show, /overlay/show. The agent hides the kiosk window before the stream and shows it after. The exit overlay (app/gui/StreamOverlay.qml) is a separate always-on-top window. It caused the Wayland orphan bug (fixed by onClosing Qt.quit, commit dd22bf17) and it drives the macOS Spaces choreography (docs/runbooks/runbook.md).
  • In-process streaming already exists upstream. app/gui/StreamSegue.qml runs a Session inside the app and loads StreamOverlay.qml itself. The CLI stream path uses it via CliStartStreamSegue.qml and CliStartStream::Launcher (app/cli/startstream.cpp). Kiosk mode bypasses it today.
  • The iPad head (hydraheadipad) is already single-app. It is the model to converge toward.

Fleet-safety constraint: code merged to hydra-experiencenet master rides the NEXT tag to the ENTIRE macOS fleet (the DMG auto-rolls). Agent releases also auto-roll to macOS agents. So Phase 1 must be inert on macOS through explicit, verifiable gating. The Linux AppImage can ship without a tag: appimage.yml workflow_dispatch does a same-version backfill, and the head refetches with the updater's force path.

2. Phase 1: Linux in-process streaming

2.1 Flow

  1. Tile tap. KioskView calls POST 127.0.0.1:9740/api/v1/stream/start, unchanged.
  2. The agent runs discovery and native pairing as today (states finding_body, pairing). Pairing on Linux is native Go, no subprocess, so it cannot disturb the kiosk.
  3. New on Linux: instead of spawning a stream subprocess, the agent stores the resolved stream parameters and sets streamStatus to a new state: ready.
  4. KioskView already polls GET /api/v1/stream/status every 2 s. On Linux it now handles ready: it fetches GET /api/v1/stream/params, applies them to StreamingPreferences, builds a CliStartStream::Launcher(host, app, prefs) in-process, and pushes StreamSegue onto the StackView. The stream runs inside the kiosk process. StreamSegue loads StreamOverlay itself and binds the Session, so Exit experience calls session.triggerExitFromMenu directly, no agent round trip.
  5. The app pushes state transitions to the agent (section 2.3). On streaming the agent starts the mic relay if the catalog asks for it. On ended or failed the agent stops the relay and returns to idle or error.
  6. When the session finishes, StreamSegue pops back to KioskView in the same process. No window/show call, no Space dance, no orphan overlay.

Note on "overlay in-scene": the video still renders in an SDL-created window, so the overlay cannot become a literal QML Item inside the stream surface. What Phase 1 delivers is the process-level version: the overlay is a child window of the one kiosk process, created and destroyed by StreamSegue, lifetime-coupled to the session. The orphan class of bugs dies because there is no second process to outlive. A literal in-scene overlay would require compositing the decoder into the Qt scene and is explicitly out of scope.

2.2 GET /api/v1/stream/params (new, agent)

Purpose: keep layer 1 of the orientation contract agent-owned. The app applies values verbatim and computes nothing.

Availability: returns 200 only while streamStatus is ready or streaming. Otherwise 409 with {"error":"no stream prepared"}.

Response:

{
  "host": "10.10.3.7",            resolved body IP from discovery (LAN or WG)
  "app": "mercator-talks",        Sunshine app name
  "orientation": "portrait",      catalog value, informational
  "resolution": "1080x1920",      layer 1: computed by the agent from orientation
  "fps": 60,
  "bitrate_kbps": 25000,          agent picks LAN 150000 vs WG 25000 by subnet
  "video_codec": "auto",          agent picks per platform (Linux: auto, issue #500 context)
  "video_decoder": "hardware",
  "audio_config": "stereo",
  "frame_pacing": true,
  "vsync": true,
  "hdr": false,
  "absolute_mouse": true,
  "quit_app_after": true,         maps --quit-after plus --force-quit-app semantics
  "microphone": false             informational; the relay stays agent-owned
}

Implementation: refactor moonlightStreamArgs into a StreamParams struct with two renderers: ToArgs() for the existing subprocess paths on all platforms (behavior-identical, covered by moonlight_test.go), and JSON for this endpoint. The /api/v1/experiences catalog already carries orientation and enable_microphone per entry (catalog.go); discovery already yields the resolved IP. No new upstream data is needed.

2.3 State reporting: the app pushes to :9740

Decision: the app pushes state transitions to the agent. The agent does not poll :9741.

New agent endpoint: POST /api/v1/stream/state with body {"state":"connecting|streaming|ended|failed","error":"...optional"}. Wire the pushes in StreamSegue callbacks (plain XHR in QML, gated per section 3):

  • connectionStarted: push streaming.
  • sessionFinished: push ended, or failed with the error text when the segue error dialog has text.
  • While streaming, a 15 s QML Timer re-asserts streaming. This makes an agent restart mid-stream self-healing: the fresh agent learns the true state within 15 s.

Why push, not poll:

  • The poll target dies exactly when the interesting event happens. If the kiosk crashes, :9741 stops answering, and a poller cannot tell crash from restart. The agent already has the authoritative liveness check for that case: isKioskRunning() (pgrep on the managed binary path, anchored per commit 8d190a1).
  • Push preserves the existing direction of truth. The kiosk already calls :9740 for config, experiences, start, stop, logs, diagnostics, version. The convergence direction is a shrinking :9741 surface, not a growing one.
  • Push carries exact transitions and error text. Polling would sample.

Backstops in the agent (both cheap):

  • ready pickup timeout: if no state push arrives within 30 s of entering ready, set error "kiosk did not pick up stream" and clear the prepared params.
  • Liveness watchdog: while status is ready or streaming in in-process mode, a goroutine checks isKioskRunning() every 2 s. If the process is gone, stop the mic relay, set error with reason "kiosk process exited", and let the main tick relaunch the kiosk.

2.4 Fate of the Linux subprocess spawn path

  • The localAPI path (tile tap, and the hydraheadflatscreen stream-start Cobra command which POSTs to the same endpoint) goes in-process whenever the kiosk is running. This is the normal case on every Linux head with the fork installed.
  • startMoonlightStream in moonlight_linux.go is KEPT for exactly one case: the client.go fallback when the fork is not installed (isHydraExperienceNetInstalled false) and a server assignment exists. Server-assigned streams with the fork installed already just launch the kiosk (client.go startKiosk), so they need no change.
  • Do not delete the subprocess code in Phase 1. Deleting is a Phase 2/3 cleanup once macOS has converged, because the platform contract (platform_contract.go) requires the symbol on every GOOS anyway.

3. Platform gating mechanics

Two independent gates, both runtime, both required. macOS behavior changes only if BOTH fail.

Gate A, Go agent: add useInProcessKioskStream() bool to the per-OS files and to platform_contract.go.

  • moonlight_linux.go: returns true when isKioskRunning() (plus an optional config kill switch in_process_stream: false in head config or config.yaml for emergency opt-out).
  • moonlight_darwin.go and moonlight_windows.go: return false as a one-line constant function.
  • localapi.go startStream branches on it. Every new call site sits behind this gate.

Gate B, QML: KioskView gets readonly property bool inProcessStreaming: Qt.platform.os === "linux". This is a runtime check inside the one shared QML codebase, not a build flag. Every new QML branch (the ready handler, params fetch, launcher creation, state pushes) is inside if (inProcessStreaming).

Double-gate effect: on macOS the agent never emits ready (Gate A) and the QML never acts on ready (Gate B). This matters because the current KioskView poll handler treats ready as terminal back-to-grid; that legacy branch stays byte-for-byte for macOS.

Reviewer checklist for macOS inertness:

  1. git diff in hydraheadflatscreen shows zero changes in any *_darwin.go and *_windows.go file except the new one-line return false gate function. This is checkable at a glance.
  2. Every hunk in localapi.go that changes behavior is inside if useInProcessKioskStream(). The subprocess branch is the unchanged else.
  3. Every hunk in KioskView.qml and StreamSegue.qml is inside the inProcessStreaming guard, or is provably a no-op when the guard is false. grep -n inProcessStreaming over the diff must cover all new lines.
  4. go test ./... green; extend moonlight_test.go to assert StreamParams.ToArgs() equals the previous moonlightStreamArgs output for landscape, portrait, LAN, and WG inputs.
  5. Runtime proof before any tag: run the new build on the Visit Flanders test Mac mini (test environment by convention) and walk one tile-tap stream. Expected: identical two-process behavior, agent log still shows the subprocess launch line.

"Byte-identical" is literal for the darwin agent code paths (no darwin file changes). The macOS DMG is not byte-identical (the QML resource changed) but is behavior-identical by the double gate; item 5 is the runtime proof.

4. Crash model and what else changes

In-process, a decoder or session crash kills the whole kiosk process. That is accepted and covered:

  • The agent main tick (client.go) relaunches the kiosk within one 30 s tick when isKioskRunning() is false. Already tested (omarchy testbook section 5).
  • The new liveness watchdog (section 2.3) detects the death within 2 s, sets streamStatus error with "kiosk process exited", stops the mic relay, and invalidates the cached body so the next attempt re-runs discovery.

Enumerated changes:

  • streamStatus state machine: idle, finding_body, pairing, ready (new), streaming, error. ready is only ever set on Linux in-process mode. streaming is set by state push (in-process) or by spawn success (subprocess, unchanged).
  • :9741 endpoints /window/hide, /window/show, /overlay/show become vestigial on the Linux in-process path. Keep them: macOS uses them until Phase 2, and the Linux no-kiosk fallback never used them. /probe stays live everywhere (discovery.go uses it for connectivity probes under the app's network identity).
  • Go helpers hideKioskWindow, showKioskWindow, showOverlayWindow: not called on the Linux in-process path. No code removal in Phase 1.
  • Heartbeat: diag["app"] currently reports kiosk, moonlight, or none from process checks (heartbeat.go). In-process streaming would report a bare "kiosk" during a stream. Add value kiosk-streaming when localAPI status is streaming in in-process mode, so the streaming monitor and admin can tell the states apart.
  • Mic relay: start moves from startStream to the state-push handler (streaming), stop moves to ended/failed/watchdog. Same agent-owned relay, same lifecycle guarantees.
  • StreamSegue.qml StackView.onDeactivating unconditionally sets toolBar.visible = true. When the segue pops back to KioskView the toolbar must stay hidden. KioskView.onActivated re-hides it, but verify there is no one-frame flash; if there is, guard with kioskMode.
  • The Wayland orphan-overlay class of bugs is structurally removed on Linux: one process owns overlay, grid, and stream lifetime.

5. Phase 2 sketch: macOS convergence (not in scope for Phase 1)

Flip the gates once Linux has soaked: useInProcessKioskStream true on darwin, QML gate widened to osx. Then:

  • Retire on macOS: the agent's hide/show/overlay round trips; the two-process Spaces choreography (kiosk Space vs stream-subprocess Space); most of the quitting-veil timing in StreamOverlay.qml, since the transition becomes same-process; the stream subprocess spawn in moonlight_darwin.go as the kiosk path (kept only as the no-fork fallback).
  • Keep: the external screenshot loop through Terminal's Screen Recording TCC grant (unchanged, deliberate); the single TCC Local Network prompt (the kiosk app is already the permission holder, which is exactly why single-app is simpler on macOS); native pairing (moonlight_cert_darwin.go already exists).
  • Verify on macOS specifically: SDL fullscreen still creates its own Space in-process; confirm the CanJoinAllSpaces overlay behavior and the veil can actually shrink before deleting choreography code.
  • Rollout order: Visit Flanders test Mac mini first (test venue), soak at least a week of daily visitor cycles, then one production venue Mac, then the tag rides the DMG auto-roll to the fleet. Phase 2 has a real point of no return at tag time; plan the rollback tag in advance.

6. Phase 1 test plan (omarchy MacBook Air)

Hardware: the omarchy MacBook Air (Haswell, H.264-only, currently kiosk_disabled; re-enable via POST /api/v1/nodes/<id>/kiosk-mode {"disabled":false} for the test window). Reference pass baseline: cranky-toaster-86. Extend docs/testbooks/omarchy-head-e2e.md:

  • Section 4 (Streaming), amended: after a tile tap, agent log shows the in-process handoff line and NOT a subprocess launch line. pgrep -f "<managed-path> stream" finds nothing during the stream; only the kiosk process exists. Codec evidence (pix_fmt vaapi, h264 decoder, no HEVC errors) now comes from the kiosk app log, not a subprocess log.
  • Section 4, new orientation step: start mercator-talks (canonical portrait). PASS: GET /api/v1/stream/params returns resolution 1080x1920; the stream renders portrait; layers 2 and 3 (hydrabody) behave exactly as before, since layer 1 values are unchanged, only their transport changed.
  • Section 5 (Kiosk lifecycle), new steps:
    • Super+W during an active stream (single-window behavior): closing the SDL stream window must end the session and return to the grid, or exit the app cleanly followed by agent relaunch within one 30 s tick. Record which of the two happens; either is acceptable, a hung black screen or an orphan overlay handle is a FAIL.
    • Stream crash recovery: kill -9 the kiosk process mid-stream. PASS: agent watchdog logs "kiosk process exited" within ~2 s, stream/status shows error, kiosk is back on the grid within one tick, next tile tap re-runs discovery (body cache invalidated).
    • Exit overlay: handle visible during the stream; Exit experience ends the session in-process and returns to the grid; agent status transitions streaming to idle via the state push, confirmed with curl.
  • New section (State reporting): curl stream/status through a full cycle and observe finding_body, pairing, ready, streaming, idle. Restart the agent mid-stream (systemctl --user restart hydraheadflatscreen; never touch hydranode). PASS: within 15 s the re-assert push restores status streaming.
  • Section 5 existing steps (kiosk kill relaunch, kiosk-mode toggle, help dialog agent version) rerun unchanged as regression.
  • Section 7 (Self-update chain): validate the same-version backfill plus forced refetch flow used for rollout (section 7 below).

7. Rollout and rollback (Phase 1)

Ship to the Air:

  1. Merge the Qt changes to hydra-experiencenet master. This alone ships nothing: the DMG only changes at the next tag, and appimage.yml only auto-runs on tags or workflow-file edits.
  2. Run appimage.yml via workflow_dispatch with the current released version (same-version backfill). This publishes a fresh Linux AppImage under the existing version with zero macOS fleet impact.
  3. On the Air, force a refetch so the updater reinstalls the same-version artifact (updater force path, or remove the managed binary at ~/.hydraheadflatscreen/bin/hydra-experiencenet and let the QtAppUpdater reinstall).
  4. Agent: tag a normal hydrahead agent release. This DOES auto-roll to macOS and Windows agents, which is why Gate A must live in per-OS files with a zero darwin/windows diff (section 3, checklist item 1).
  5. Alternative for the Qt side: wait for the next real tag; the QML rides to macOS inert.

Rollback: reinstall the previous AppImage from the release server (prior versions remain published) at the managed path, and hold the updater until a fixed backfill is published. Agent: tag the previous version again or use the updater pin. Both are per-head operations on Linux; no fleet action needed.

Point of no return: there is NONE in Phase 1 if the gating holds. The Linux artifact ships tagless via backfill and rolls back per-head. The macOS DMG changes only at the next tag, and what it carries is double-gated dead code. The first true point of no return in the whole program is the Phase 2 macOS tag.

8. Effort estimate and Phase 1 touch list

Phase 1: 8 to 10 person-days.

  • Agent (Go), ~3 pd:
    • pkg/client/moonlight.go: StreamParams struct, ToArgs(), JSON rendering; behavior-identical args.
    • pkg/client/moonlight_test.go: args equivalence tests (landscape, portrait, LAN, WG).
    • pkg/client/localapi.go: ready state, GET /api/v1/stream/params, POST /api/v1/stream/state, pickup timeout, liveness watchdog, mic relay rewiring, gate branch in startStream.
    • pkg/client/moonlight_linux.go: useInProcessKioskStream (true when kiosk running, plus kill switch).
    • pkg/client/moonlight_darwin.go, pkg/client/moonlight_windows.go: return false gate only.
    • pkg/client/platform_contract.go: add the gate symbol.
    • pkg/client/heartbeat.go: kiosk-streaming app value.
  • Qt app, ~4 pd:
    • app/gui/KioskView.qml: inProcessStreaming gate, ready poll branch, params fetch, launcher creation, segue push, state pushes on segue signals.
    • app/gui/StreamSegue.qml: state push hooks (connectionStarted, sessionFinished), 15 s re-assert timer, toolbar guard check; all gated.
    • app/cli/startstream.{h,cpp}: expose Launcher creation to QML (qmlRegisterType or a factory on KioskBridge); no logic change.
    • app/platform/kioskbridge.{h,cpp} (or a small new helper): applyStreamParams(StreamingPreferences*, json) mapping the params document onto preferences (resolution, fps, bitrate, codec, decoder, audio, absolute mouse, frame pacing, vsync, hdr, quit-after).
    • app/api/localserver.cpp: no changes required in Phase 1 (endpoints stay for macOS).
    • docs/runbooks/runbook.md: update the window-consolidation section to point here.
  • Testbook and validation on the Air, ~2 pd: docs/testbooks/omarchy-head-e2e.md amendments (section 6 above) plus a full pass.
  • Rollout mechanics and soak, ~1 pd.

Phase 2 (macOS convergence, sketch level): 4 to 6 person-days including the Visit Flanders soak and choreography retirement, to be re-estimated in its own design issue after Phase 1 soaks.

SUPERSEDED by #507 (video in the Qt scene graph, true single-app). The agent-side API sections here carry over unchanged into #507: 2.2 stream/params, 2.3 stream/state and watchdog, 3 double gating, 7 rollout mechanics.