Skip to content

Live screen streams of leased devices #593

Open

Description

@V3RON

Request: none

PoC: #576 (feasibility and cost, iOS only; its handoff comment covers the Android work on feature/vendor-provenance).

Problem

An operator cannot see what is on a leased device. An agent drives a simulator or an emulator on a worker, maybe a machine in another room, behind a gateway. When the agent hangs, loops or fails a test, the only way to see the screen is to log in to that machine and find the right window. Nothing in Simlock shows a picture.

Who it is for

  • An operator watching agents' devices in the console, on any lease in the fleet.
  • An agent or a tool that holds a lease and wants a live picture of its own device over the HTTP API, for example to record a failing run.

Outcome

  • In the console, a lease's page has a Screen panel. Pressing Show screen shows the device's screen live. Pressing Stop, leaving the page or closing the tab stops it.
  • Over HTTP, one GET on a lease's stream URL returns the live picture for as long as the connection stays open.
  • This works for leases on iOS simulators and on Android emulators, on a single worker and through a gateway, with the same URL and the same bytes.
  • Nothing is captured while nobody looks. A capture never outlives its viewer or its lease.

How it works for the user

sequenceDiagram
  participant U as Operator (console)
  participant G as Gateway
  participant W as Worker
  participant D as Device
  U->>G: Show screen (GET /v1/stream/leases/L1)
  G->>G: lease L1 known, caller may see it?
  alt unknown, or another agent's lease
    G-->>U: 404 UNKNOWN_LEASE
  end
  G->>W: relay to the worker that holds L1
  W->>W: lease still held by that requester?
  W->>D: start capture
  alt device cannot be filmed (physical device)
    W-->>U: 415 STREAM_NOT_SUPPORTED
  else capture tool missing on the worker
    W-->>U: 503 STREAM_UNAVAILABLE
  end
  W-->>U: 200, picture headers, then frames
  U->>G: Stop / leave page (request closed)
  G->>W: relay cancelled
  W->>D: capture stopped
  Note over W,D: the lease ends while watched: capture stopped, the stream ends
Loading

Before (any daemon today):

$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
    http://gw:7777/v1/stream/leases/L1
404

After (stream.enabled: true on the gateway and the worker):

$ curl -sD - -o screen.bin -H "Authorization: Bearer $TOKEN" \
    http://gw:7777/v1/stream/leases/L1
HTTP/1.1 200 OK
content-type: application/vnd.simlock.stream+h264
simlock-stream-codec: h264
simlock-stream-width: 330
simlock-stream-height: 720
simlock-stream-fps: 30
x-simlock-lease: L1
^C        # closing the request stops the capture on the worker

$ curl -s -H "Authorization: Bearer $TOKEN" http://gw:7777/v1/stream/leases/L9
{"error":{"code":"STREAM_NOT_SUPPORTED","message":"This lease is on a physical device, and a live screen is captured from a simulator or an emulator only"}}

Examples

Request Today After
GET own lease, iOS simulator 404, no such route 200, picture
GET own lease, Android emulator 404, no such route 200, picture
GET own lease, physical iPhone or Android phone 404, no such route 415 STREAM_NOT_SUPPORTED
GET another agent's lease, agent token 404, no such route 404 UNKNOWN_LEASE, same as unknown
GET any lease, operator token 404, no such route 200, picture
GET a lease id that does not exist, or ended 404, no such route 404 UNKNOWN_LEASE
GET with an id that is not URL-decodable (%E0%A4%A) 404, no such route 404 UNKNOWN_LEASE
POST to the stream URL 404, no such route 405 BAD_REQUEST
GET with stream.enabled: false 404, no such route 404, no such route (unchanged)
GET on an iOS worker whose capture tool is missing 404, no such route 503 STREAM_UNAVAILABLE
Gateway: the lease's worker has streaming off 404, no such route 503 STREAM_UNAVAILABLE, with the worker's reason
Gateway: the lease's worker is disconnected 404, no such route 503 WORKER_UNREACHABLE

What could go wrong

  • A picture freezes and looks live. Likely only if a capture stalls without ending; a stream that goes 30 s without a frame ends instead.
  • A capture keeps running after its lease ends, on a device someone else now holds. Unlikely: the lease end stops it, and the device's reclaim sweeps what is left.
  • An agent sees another agent's screen. Unlikely: ownership is checked on the gateway and again on the worker.
  • An Android stream is slow or choppy (about 21 fps while scrolling, measured under heavy host load). Likely on busy machines; the encoder measurement below says how much.
  • A worker's CPU rises while a screen is watched: one capture per viewer.

Words used

  • stream: one open GET /v1/stream/leases/{leaseId} and the picture it carries, e.g. the console's Screen panel while it is on.
  • capture: the process (or device-side server) that films one device for one stream, e.g. one simlock-screen-capture per viewer of a simulator.
  • envelope: the body's framing: 4-byte big-endian length (counting the tag), 1-byte tag, payload. Tag 0x05 JSON meta first, 0x01 codec description (avcC), 0x02 key frame, 0x03 delta frame.
  • key frame: a frame a decoder can start from; a delta frame needs the frames before it.
  • relay target: a name a gateway sends a relayed HTTP exchange to on a worker, e.g. agent-device, and now stream.
  • capability: an optional module a worker reports with a state (available, unavailable with a reason, off), e.g. stream: unavailable (the screen capture program is not installed).
  • scrcpy server: the small Android program from the scrcpy project that encodes an Android screen on the device; Simlock ships it unchanged.
  • smart socket: adb's own way to reach a socket inside a device through the adb server (host:transport:<serial>, then localabstract:<name>), with no host port opened.

Non-goals

  • Input. No touches, keys or text through the stream; v1 is view-only. Named follow-up.
  • Physical devices, iOS and Android. Refused in v1.
  • Audio.
  • Recording or saving streams on the worker, and any history of who watched.
  • A CLI command. The console and the HTTP API only.
  • Choosing a device by whether it can be streamed (requires), and showing "being watched" anywhere.
  • A GPU against software encoder measurement for Android emulators. Named follow-up; it decides whether a faster Android picture is worth a config change.
  • Any codec but H.264.

Completion conditions

  • With stream.enabled: true, an operator presses Show screen on a leased iOS simulator in the console, on a single worker, and sees the screen move as an agent drives it.
  • The same on a leased Android emulator.
  • The same through a gateway, for both platforms.
  • GET /v1/stream/leases/{leaseId} answers each row of Examples with the status and code shown.
  • Closing the request ends the capture on the worker: no capture process, no app_process for scrcpy in the emulator, and no scrcpy jar left in /data/local/tmp.
  • Releasing a lease while it is watched ends the stream, and leaves the same nothing behind.
  • With stream.enabled: false, no stream route exists and the worker reports the stream capability off.
  • The npm package carries the iOS capture tool and the scrcpy server; nothing is downloaded to stream.
  • The capture tool and the scrcpy server sit under vendor/ with their license texts and a NOTICE.md each, and the check that enforces it passes.

Open questions

Decisions

Technical spec

Facts

# Fact Source
F1 Leasing calls each LeaseEndParticipant.leaseEnded(ended, signal) after a lease end commits, before reclaim. src/leasing/lease-ports.ts:37 (main)
F2 A driver already has one optional capability pair: passthroughTools and passthrough?(…). src/core/driver.ts:356-363 (main)
F3 A driver's lease environment is per device: leaseEnvironment(device). src/core/driver.ts:349 (main)
F4 The PoC flattens every driver's virtualLeaseEnvironment() into one environment for the capture tool, device-blind. src/daemon/main.ts:380 (feature/vendor-provenance)
F5 The gateway relay arms one timer over the whole exchange, 330 s by default; a body that stalls after its head is errored by it. src/gateway/relay.ts:23, :82-90 (main); src/daemon/relay-gateway.e2e.test.ts:551
F6 A worker looks a relay target up by name and refuses an unknown one with RELAY_TARGET_MISSING. src/daemon/relay.ts:100-110 (main)
F7 A capability name must match ^[a-z][a-z0-9-]{0,31}$; stream passes. src/contract/schemas.ts:1054 (main)
F8 The gateway forwards a namespaced requester, requesterPrefix + the lease's own requester. src/gateway/fleet-coordinator.ts:434, src/gateway/lease-index.ts:98 (main)
F9 GET /v1/leases/{id}: an agent sees its own lease, an operator any. The console needs an operator token. docs/HTTP-API.md:700, docs/CONSOLE.md:8 (main)
F10 Physical Android devices use the host's default adb server, never Simlock's. docs/internal/agent-rules/safety.md:106 (main)
F11 device.exec emits no event, on purpose: no state change, and it would flood the 1000-entry ring. docs/internal/EVENTS.md:212-219 (main)
F12 A byte channel resets after a write waits 60 s for credit; a writer that writes no more than credit never waits. ADR 0028 §1, §2
F13 The PoC reads a worker's refusal body unbounded and passes any code string through. src/stream/refusal.ts:39-64 (feature/vendor-provenance)
F14 The PoC registers a capture only after its first frame arrived. src/stream/worker-link.ts:75 (feature/vendor-provenance)
F15 The PoC spawns the tool with node:child_process and times silence with setTimeout (30 s); its silence test fails about 1 run in 5. src/stream/capture.ts:29, :35 (feature/vendor-provenance); #576 body, Known gaps
F16 The PoC refuses every lease but an iOS simulator. src/stream/worker-link.ts:51 (feature/vendor-provenance)
F17 The iOS tool finds the device through SIMLOCK_IOS_DEVICE_SET; unpatched serve-sim sees only the default set and answers Device <UDID> not found. vendor/screen-capture/Sources/simlock-screen-capture/FrameCapture.swift:424, vendor/screen-capture/NOTICE.md:94 (feature/vendor-provenance)
F18 The iOS tool paints at least every 200 ms when idle. FrameCapture.swift:44 (feature/vendor-provenance)
F19 The scrcpy server is vendored at SHA-256 764eb6f7…bc536. vendor/scrcpy/SHA256SUMS.txt (feature/vendor-provenance)
F20 iOS measured: 18.6–23.2 fps on swipes, 880–1097 kbit/s at 720 px, 66–78 MB RSS; the relay costs nothing measurable. #576 body, Measured
F21 Android measured on an emulator: 0.7–1.3 fps idle, about 21 fps / 583 kbit/s scrolling, host load 20–36. #576 handoff comment, Findings
F22 scrcpy's payload is Annex-B, not AVCC. #576 handoff comment, Findings
F23 On fix/stream-relay-deadline the relay's 330 s deadline covers only the channel wait and the head; after it an idle timer (DEFAULT_IDLE_MS = 330_000, GatewayRelayOptions.idleMs) restarts on every chunk and ends the exchange WORKER_UNREACHABLE. agent-device's proxy aborts every exchange 300 s after it starts. commit 1c4ab3a8, src/gateway/relay.ts

Modules touched

flowchart LR
  MAIN["module host (ADR 0030)"] --> ST["stream/ (new) *"]
  ST --> LP["leasing: LeaseEndParticipant"]
  ST --> SCR["core/screen.ts: Driver.screenCapture *"]
  IOS["drivers/ios *"] --> SCR
  AND["drivers/android *"] --> SCR
  IOS --> VS["vendor/screen-capture (binary in package) *"]
  AND --> VJ["vendor/scrcpy (jar) *"]
  GW["gateway/relay.ts: head deadline, body idle timer *"]
  ST -. gateway ctx.relay .-> GW
  UI["ui/src/screen *"] -. HTTP .-> ST
Loading

(* = new or changed.)

sequenceDiagram
  participant B as Browser
  participant G as Gateway stream
  participant R as Relay + channel
  participant W as Worker stream
  participant D as Driver screen
  B->>G: GET /v1/stream/leases/L1
  G->>G: lease index: L1 on W, caller owns it; W reports stream available
  G->>R: relay(W, GET, namespaced requester)
  R->>W: relay target "stream"
  W->>W: registry: requester matches; register capture
  W->>D: screenCapture.open(device, settings)
  D-->>W: envelope bytes (meta first)
  W-->>R: 200 + picture headers
  R-->>G: head (deadline ends, 330 s idle timer starts)
  G->>G: check header values
  G-->>B: 200, then bytes unparsed
  alt worker refuses
    W-->>G: 4xx/5xx JSON, read up to 16 KiB, code shape-checked
    G-->>B: same status and code
  end
Loading

Surface and extension points

  • src/stream/index.ts: the module's definition for ADR 0030's registry, and nothing else the host needs.
  • src/core/screen.ts: ScreenCapture, ScreenFeed, ScreenNotSupportedError (415) and ScreenUnavailableError (503); Driver.screenCapture? (ADR 0031 §4). The second implementation exists (Android), so the one shape is fixed now. The envelope's tag values and meta shape sit beside it.

Contract and event changes

  • No new contract operation; no protocol bump from this feature.
  • New HTTP route GET /v1/stream/leases/{leaseId}; codes STREAM_NOT_SUPPORTED (415) and STREAM_UNAVAILABLE (503) join the HTTP error table.
  • Config: stream.enabled, stream.fps, stream.maxDimension, stream.bitrateKbps. No stream.command; drivers.ios.screenCaptureCommand overrides the packaged iOS capture program.
  • Events: none. Both EVENTS.md files gain a paragraph saying a stream emits no event and why (ADR 0031 §8).

Other code on the same state

  • Leasing's lease end and reclaim (F1): the lease-end hook closes every capture of that lease and waits for the closes (bounded by each driver's close and leasing's signal); the reclaim then sweeps scrcpy jars on that emulator. Lease end wins.
  • The Android driver's private adb server: the scrcpy client's adb push and shell, and its smart-socket connection, go through it, scoped to the emulator's serial. The driver's dispose stops captures before it stops the adb server (ADR 0030 beforeDriversDispose).
  • The iOS driver's shutdown and erase of a simulator: a capture of a device being shut down fails, and its stream errors rather than freezing.
  • agent-device's relay target (F6): a second name in the same table; neither reads the other's exchanges.
  • The link's channels (F12): a stream holds one channel for its life; at 64 open channels a new relay waits.

Rules in play

  • architecture.md 1–3, 9, 11, 12, 15, 18–21: the core names no platform; ports for spawn, timers and sockets; every wait bounded; every capture ends in one state.
  • safety.md 4, 9, 10: no download; Simlock's own adb server for emulators; worker answers and headers are claims.
  • events.md: no events, and the catalogue says so.
  • vendor-and-third-party-code.md (lands with this feature from feature/vendor-provenance).
  • documentation.md: docs/HTTP-API.md, docs/CONFIGURATION.md, docs/CONSOLE.md, and the src/stream/AGENTS.md manual.

Tests

Seam: the HTTP app over a worker daemon with fake drivers, and the gateway–worker in-memory pair (src/daemon/relay-gateway.e2e.test.ts); drivers' screen code against a fake process runner and socket port (src/drivers/android/*.test.ts).

  • each row of Examples answers the status and code shown, on a worker and through a gateway.
  • a lease that ends while its capture is starting stops that capture once it starts.
  • a lease that ends while watched ends the stream and closes the driver's screen.
  • closing the request closes the driver's screen, before and after the first frame.
  • a capture silent for 30 s ends its stream with an error, on a fake clock.
  • a relayed body that keeps sending outlives the 330 s deadline; one silent for the idle time ends WORKER_UNREACHABLE with relay.cancel; a head that does not arrive in time fails WORKER_UNREACHABLE (any relay target).
  • a worker refusal body over 16 KiB, or with a code that does not match the shape, reaches the caller as STREAM_UNAVAILABLE.
  • a worker 200 with a missing or out-of-range picture header reaches the caller as STREAM_UNAVAILABLE.
  • the gateway forwards the worker's body bytes unchanged.
  • an operator sees any lease; an agent with another agent's lease gets the unknown-lease answer.
  • the module turned off mounts no route and reports off.
  • iOS: the tool is spawned with that device's set and the picture options; a physical device is refused not-supported; a missing binary reports unavailable.
  • Android: the jar is pushed to a per-capture path, the server started with the fixed options, the device socket reached through host:transport:<serial> and localabstract:scrcpy_<scid> with no adb forward; Annex-B becomes AVCC with an avcC description; deltas are dropped only until the next key frame; a physical device is refused not-supported.
  • Android reclaim removes Simlock's scrcpy jars for that serial.
  • a lease that ends after a capture's first frame but before its 200 closes the capture and answers the unknown-lease refusal.
  • slow lane: a leased iOS simulator and a leased Android emulator each stream through a gateway, and after Stop nothing is left on the worker or in the emulator.

Written by an agent.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature:specBusiness or technical spec in progress.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions