Skip to content

Literally rewriting basically everything because this was a bad idea on an architectural level - #4

Open
spacedouut wants to merge 6 commits into
mainfrom
devin/1792681138-rewrite
Open

spacedouut wants to merge 6 commits into
mainfrom
devin/1792681138-rewrite

Conversation

@spacedouut

@spacedouut spacedouut commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

AKA: captured v1.

Summary

Rewrites captured from scratch. The control and media protocol stays wire-compatible with the agent (src/backend/captured.go), so the agent needs no changes.

Layout

  • main.go: the daemon (control socket, media socket, flags).
  • pipelines/<source>_<capturer>_<os>.go: one pipeline per file.
    • kms_nvidia_linux.go
    • pipewire_mutter_linux.go
    • kms_linear_linux.go
    • screencapturekit_darwin.go
  • Shared code:
    • pipeline.go: interface and priority registry.
    • stream.go: pacing, buffer reuse, and a one-frame mailbox that drops stale frames.
    • drm_linux.go and egl_linux.go: native bindings. Everything is still loaded through purego, so there's no cgo.

Behavior changes

  • No synthetic fallback. A pipeline that can't capture returns an error saying why. The old kms path silently streamed a gradient on NVIDIA because the scanout buffer is tiled (modifier 0x300000000606014).
  • New kms_nvidia pipeline. It imports the scanout dma-buf into EGL (GBM platform) with its modifier, attaches it to an FBO and reads it back with glReadPixels, so the GPU does the de-tiling. Like Sunshine's KMS capture, it needs CAP_SYS_ADMIN.
  • KMS re-reads the CRTC's framebuffer every frame. Compositors page-flip between buffers.
  • Display list. Only lit/active outputs are listed, with their current mode, a name (DP-1) and position. Before, pipewire listed a switched-off monitor with a bogus mode, and starting it failed with Monitor not active.
  • pipewire_mutter no longer runs videorate, so it stops padding with duplicate frames. Frames are now damage-driven, with a 1 s keepalive.
  • Protocol additions, all additive:
    • a new stats command;
    • width, height and pipeline in stream-started;
    • pipeline and pipelines in info.
  • New flags: --pipeline auto|<name>, and --list, which shows every pipeline, whether it works on this machine, and its displays.
  • Disconnecting from the media socket stops the stream.

Measured on observatory (RTX 2070S, GNOME Wayland, DP-1 2560×1440@120), with the new cmd/capbench:

fps grab avg frame gap p99 CPU
old kms (synthetic gradient, not real frames) 47 of 60 n/a 27 ms 127%
old pipewire 50–56 of 60 n/a 37–144 ms ~65% + 27% gst
new kms_nvidia @60 60.2 1.7 ms 18.5 ms 27%
new kms_nvidia @120 120.4 1.8 ms 9.7 ms 58%
new kms_linear (virtio 1280×800) 60.3 0.4 ms 17.7 ms 8% (old: 33%)

A dumped kms_nvidia frame shows the real desktop, the right way up, with correct colors.

Not done or not tested

  • No Windows pipeline. The old pipelines/windows was an empty file.
  • The macOS pipeline is a port of the old sckit code. It passes GOOS=darwin go vet but hasn't been run.
  • kms_nvidia doesn't capture the cursor, which is on its own hardware plane.
  • Not tested end-to-end through the agent.
  • pipewire hasn't been tested under motion. Every run so far was on an idle desktop.

Link to Devin session: https://app.devin.ai/sessions/c5b4a4a36d4e4d6d8a6901ae9001ab84
Open in Devin Desktop: https://app.devin.ai/desktop/session/c5b4a4a36d4e4d6d8a6901ae9001ab84?variant=devin
Requested by: @spacedouut

Summary by CodeRabbit

  • New Features
    • Added automatic and named capture-pipeline selection, display discovery, and streaming for NVIDIA KMS, linear KMS, Mutter PipeWire, and macOS ScreenCaptureKit.
    • Added a benchmark command reporting frame rate, throughput, timing, and duplicate frames, with an option to save a frame.
    • Added control and media streaming over Unix sockets, with optional TCP control access.
  • Changes
    • Capture failures now report an error instead of producing a synthetic image.
    • The previous X11 and PipeWire capture options are no longer available.

…synthetic fallback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Walkthrough

The daemon now selects registered display-capture pipelines, serves JSON-lines control requests and BGRA media frames, and supports Linux KMS, Linux Mutter PipeWire, and macOS ScreenCaptureKit backends. The change adds pooled frame streaming, statistics, and capbench, and removes the former source-selector implementations and synthetic fallback.

Changes: Display Capture Daemon

Layer / File(s) Summary
Pipeline contract and frame streaming
pipelines/pipeline.go, pipelines/stream.go, pipelines/stream_test.go, pipelines/linux/pulled_stream_test.go
The pipeline API adds registration, probing, display selection, pooled frames, and stream statistics. Stream tests cover delivery, dropped frames, cleanup, and startup errors.
Linux KMS capture
pipelines/drm_linux.go, pipelines/egl_linux.go, pipelines/kms_*_linux.go, pipelines/linux/*
Linux KMS discovery and framebuffer helpers support linear and NVIDIA capture. The former Linux KMS, GBM, mapping, source-selection, X11, and synthetic-capture implementations and related tests are removed.
Linux Mutter PipeWire capture
pipelines/pipewire_mutter_linux.go, pipelines/linux/pipewire.go
The new backend discovers current Mutter monitors, starts a screencast session, and reads BGRA frames through GStreamer. The previous Linux PipeWire implementation is removed.
macOS ScreenCaptureKit capture
pipelines/screencapturekit_darwin.go, pipelines/macos/pipeline.go, pipelines/windows/pipeline.go
The ScreenCaptureKit backend lists displays and starts BGRA streams. The previous macOS pipeline and the empty Windows pipeline package file are removed.
Daemon control and media serving
main.go, README.md
The daemon selects named or automatically probed pipelines, serves JSON-lines control requests, and streams dimension-prefixed BGRA frames over a media socket. The README documents the pipelines, flags, protocol, and commands.
Capture benchmark
cmd/capbench/main.go
The capbench command measures frame timing and throughput, counts duplicate frames, and can report process CPU usage or write the last frame as raw BGRA.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant capbench
  participant captured
  participant pipelines.Pipeline
  capbench->>captured: Send JSON-lines start request
  captured->>pipelines.Pipeline: Start display stream at requested FPS
  pipelines.Pipeline-->>captured: Return stream and first frame
  captured-->>capbench: Return stream dimensions
  capbench->>captured: Connect to media socket
  captured-->>capbench: Send frame dimensions and BGRA data
Loading
Merge Risk: Moderate · up to bb9cc

Several capture paths can fail or misbehave in realistic setups. Automatic pipeline selection on NVIDIA machines without the required capability will fail at start instead of falling back, a stalled source can lock up stop and stats requests, KMS capture can return stale frames, and rotated monitors may not negotiate. Resolve these before merging.

Pre-merge checks: Passed 3 · Failed 1 · Inconclusive 1

Failed Failed checks: 1 warning · 1 inconclusive

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 18.42% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 11 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check Inconclusive The title indicates a broad rewrite, but it uses informal and vague wording and does not identify the main technical changes, such as the pipeline registry, native capture pipelines, or removal of syn… Replace the title with a concise, specific summary of the primary architectural change, such as "Rewrite capture architecture with native pipeline registry and KMS capture".
Passed Passed checks: 3 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 18.42% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 11 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Full details: Title check

Explanation

The title indicates a broad rewrite, but it uses informal and vague wording and does not identify the main technical changes, such as the pipeline registry, native capture pipelines, or removal of synthetic fallback.

Resolution

Replace the title with a concise, specific summary of the primary architectural change, such as "Rewrite capture architecture with native pipeline registry and KMS capture".

✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 2 findings need your review

Devin fixed 8 of 10 findings on f1e9c89. Click a finding below to jump to its comment.

For your review (2)

Fixed by Devin (8)

  • Windows desktop handles leak across streams
  • First display blocks all Windows capture
  • Page flips can end KMS streams
  • Automatic capture chooses unusable NVIDIA pipeline
  • Linear capture emits transparent desktop pixels
  • Default macOS frame rate reaches ScreenCaptureKit as zero
  • Suballocated linear scanouts fail to map
  • Oversized frame rate terminates capture daemon

View all findings in Devin Review

Devin Review

coderabbitai[bot]

This comment was marked as resolved.

spacedouut and others added 3 commits October 22, 2026 15:12
…GB, aligned mmap, fps clamp, first-frame timeout, rotated mutter monitors

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…size

Closing the sckit stream from another thread while grab sat in NextFrame freed the handle under it and the frame wait never woke, so stop-stream hung forever. grab now waits in 100 ms slices and checks a stop flag; the stream is only closed on the stream thread after grab returns.

Display ids are indexes like the Linux pipelines (main display first), so display 0 works. Width/height come from the CoreGraphics display mode in pixels and the stream asks for that size, since ScreenCaptureKit's display size is in points and would downscale on Retina.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment thread main.go
Comment on lines +80 to +86
if *listenTCP != "" {
tl, err := net.Listen("tcp", *listenTCP)
if err != nil {
log.Fatal(err)
}
log.Printf("tcp: %s", *listen)
}

var stream pipelines.FrameStream
var streamMu sync.Mutex

serveConn := func(conn net.Conn) {
defer conn.Close()
dec := json.NewDecoder(conn)
enc := json.NewEncoder(conn)
for {
var req struct {
Type string `json:"type"`
DisplayID uint32 `json:"display_id,omitempty"`
FPS int `json:"fps,omitempty"`
}
if err := dec.Decode(&req); err != nil {
return
}
switch req.Type {
case "list-displays":
displays, err := pipeline.ListDisplays(ctx)
if err != nil {
enc.Encode(map[string]string{"error": err.Error()})
continue
}
enc.Encode(map[string]any{"type": "displays", "displays": displays})

case "start-stream":
if req.FPS == 0 {
req.FPS = 60
}
streamMu.Lock()
if stream != nil {
stream.Close()
}
s, err := pipeline.StartStream(ctx, req.DisplayID, req.FPS)
if err != nil {
streamMu.Unlock()
enc.Encode(map[string]string{"error": err.Error()})
continue
}
stream = s
os.Remove("/tmp/captured-media.socket")
ml, err := net.Listen("unix", "/tmp/captured-media.socket")
if err != nil {
stream.Close()
stream = nil
streamMu.Unlock()
enc.Encode(map[string]string{"error": err.Error()})
continue
}
go func() {
<-ctx.Done()
ml.Close()
}()
go func() {
mc, err := ml.Accept()
ml.Close()
if err != nil {
return
}
defer mc.Close()
for f := range s.Frames() {
buf := make([]byte, 8+len(f.Data))
binary.BigEndian.PutUint32(buf[0:4], uint32(f.Width))
binary.BigEndian.PutUint32(buf[4:8], uint32(f.Height))
copy(buf[8:], f.Data)
if _, err := mc.Write(buf); err != nil {
return
}
}
}()
streamMu.Unlock()
enc.Encode(map[string]any{
"type": "stream-started",
"socket": "/tmp/captured-media.socket",
"format": "bgra",
})

case "stop-stream":
streamMu.Lock()
if stream != nil {
stream.Close()
stream = nil
}
// os.Remove("/tmp/captured-media.socket") - do NOT fucking do this
streamMu.Unlock()
enc.Encode(map[string]string{"type": "stream-stopped"})

case "info":
formats := pipeline.SupportedFormats()
s := make([]string, len(formats))
for i, f := range formats {
s[i] = string(f)
}
enc.Encode(map[string]any{
"type": "info",
"formats": s,
"version": "0.1.0",
})

default:
enc.Encode(map[string]string{"error": fmt.Sprintf("unknown type: %s", req.Type)})
}
}
}

accept := func(l net.Listener) {
for {
conn, err := l.Accept()
if err != nil {
return
}
go serveConn(conn)
}
}

go accept(ul)
if tl != nil {
go accept(tl)
log.Printf("control: tcp %s", tl.Addr())
go s.accept(tl)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Remote clients can control desktop capture

With --listen :9090, unauthenticated clients can send start-stream and stop-stream. They can replace or interrupt the active capture without accessing the local media socket.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: --listen gives control to anyone who can reach the port, with no auth. It's opt-in (off by default). Whether to bind it to loopback only or drop it is a design call I've passed to @spacedouut, so I'm leaving it unchanged until then.

Comment thread main.go
Comment on lines +209 to +210
os.Remove(*mediaPath)
ml, err := net.Listen("unix", *mediaPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Other local users can read desktop frames

A local user can connect to the media socket before the agent and receive raw frames. net.Listen creates it without restrictive permissions or peer checks.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: both sockets are created with default permissions, so other local users can connect to them. The proposed fix is chmod 0600 after net.Listen. I've asked @spacedouut whether the agent ever runs as a different user, which would break that, before changing it.

@devin-ai-integration

Copy link
Copy Markdown

Ran screencapturekit on a macOS 26.6.2 arm64 VM (one 1600x1200 display, 1x, 60 Hz), Go 1.26.10, at 125e5df. go vet ./... && go build && go test ./... pass (and GOOS=linux go vet).

run fps (capbench) gap p50 / p99 / max ms CPU (captured) grab_avg_ms stats frames / dropped
static 45.5 20.40 / 34.00 / 41.56 60.5% 17.57 286 / 57
moving (rAF animation in Chrome) 37.8 25.11 / 37.93 / 174.41 60.1% 18.04 279 / 88

capbench's CPU line reads /proc so it prints nothing on macOS. The CPU column is ps cputime over the whole capbench run, as % of one core.

What was broken in screencapturekit_darwin.go:

  • stop-stream hung forever. interrupt() called sckit.Stream.Close() from another thread while grab sat in NextFrameBGRA with no deadline. That frees the dylib handle under the waiting call, and its semaphore never fires again, so stream.Close blocked on <-done and capbench never got its stream-stopped. Now grab waits in 100 ms slices and checks a stop flag, and the sckit stream only gets closed in close() on the stream thread. stop, restart and SIGTERM mid-stream all work now.
  • -display 0 (capbench's default) failed with display 0 not found, because IDs were raw CGDirectDisplayIDs (1 here). IDs are now indexes, main display first, the same as the Linux pipelines. The CG id goes in name (cg1).
  • Retina would have been captured downscaled. sckit's display size and its default stream size are SCDisplay.width/height, which are points. Width/height now come from the CoreGraphics display mode's pixel size (purego), and the stream asks for exactly that with WithResolution. The refresh rate also comes from the mode now. This VM only offers 1x modes, so I couldn't watch it happen on a real Retina screen. Here points = pixels = 1600x1200, and the frames are 1600x1200.

Frames are the real screen, the right way up, with the cursor. Colour swatches read back exactly: (255,0,0), (0,255,0), (0,0,255), (255,255,0), (0,0,0), white 255. The static frame vs screencapture gives PSNR 47.6 dB (the differences are the cursor and the clock).

On a static screen sckit re-delivers the last buffer at about 57/s, so a static stream keeps running at full rate (227/228 identical in the static run) instead of going quiet. The daemon grabs about 57 fps (grab_avg ~17.6 ms), but capbench only receives 35-45. The dropped count shows that gap is lost on the media socket side (7.7 MB frames at ~270-350 MB/s), not in ScreenCaptureKit. I left main.go and stream.go alone.

Screen Recording: nothing to grant. This VM already has it granted to its agent process, and binaries started from its shell inherit it.

Static:
static

Moving:
moving

Written by Devin

Enumerates every attached output with friendly name, desktop position and
DisplayConfig refresh rate. AcquireNextFrame drives the stream (paced=false);
on WAIT_TIMEOUT the last staged frame is re-sent, and ACCESS_LOST recreates
the duplication. Frames are copied to a staging texture, mapped and packed
to tight BGRA honoring RowPitch and output rotation.

Sockets default to %TEMP% on Windows. capbench takes the media socket from
the start-stream reply and reads CPU with GetProcessTimes on Windows.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown

Added the Windows dxgi pipeline (pipelines/dxgi_windows.go, e6034c6). No NVIDIA-specific code, so it keeps the plain name.

Tested on Windows Server 2022, 1 output (IddSampleDriver, 1600x1200@60). go vet/build/test pass on windows, and GOOS=linux / GOOS=darwin vet are clean.

dxgi:
  0 DISPLAY2 (Linux FHD) 1600x1200@60.00 +0+0

capbench -secs 5, 60 fps requested:

fps gap p50 gap p99 gap max CPU (1 core) grab_avg_ms
static 57.0 17.65 ms 20.71 ms 21.66 ms 12% 17.6
motion (full-screen canvas animation) 57.2 17.52 ms 20.62 ms 21.47 ms 23% 17.5

On the static run 285 of 286 frames were identical to the one before, so those are timeout re-sends. On the motion run it was 54 of 287, which is Chrome dropping frames on this GPU-less box.

The first static run only got 39.7 fps (p50 24.4 ms). That's because AcquireNextFrame's timeout rounds to the 15.6 ms system tick. The grabber now holds timeBeginPeriod(1) while it's open, and the numbers above are with that fix.

Dumps converted to PNG are the real desktop and the colors are right:

static desktop
motion

Not tested here: ACCESS_LOST recovery (I didn't want to trigger UAC or the lock screen on this box), rotated outputs, and multiple monitors.

Written by Devin

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@spacedouut spacedouut changed the title Rewrite captured: one file per pipeline, real NVIDIA KMS capture Literally rewriting basically everything because this was a bad idea on an architectural level Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant