From 1fd020d387a4edb9777260b9ee12bb1cd32f18b6 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 17:25:55 +0900 Subject: [PATCH 01/15] Add restore-token reset control command Let control clients clear one target's Wayland portal restore token and reconnect it without rewriting other credentials. Preserve receiver ports and reject shared capture groups so unrelated streams stay intact. Co-authored-by: minpeter Generated with [OpenAI Codex](https://openai.com/codex/) --- README.md | 7 + cmd/doubletake-ctl/main.go | 8 +- internal/airplay/credentials.go | 18 +++ internal/airplay/credentials_clear_test.go | 86 +++++++++++ internal/daemon/daemon.go | 4 + internal/daemon/daemonclient/client.go | 6 + internal/daemon/daemonclient/client_test.go | 52 +++++++ internal/daemon/reset_restore_token.go | 92 +++++++++++ internal/daemon/reset_restore_token_test.go | 161 ++++++++++++++++++++ man/man1/doubletake-ctl.1 | 9 ++ 10 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 internal/airplay/credentials_clear_test.go create mode 100644 internal/daemon/daemonclient/client_test.go create mode 100644 internal/daemon/reset_restore_token.go create mode 100644 internal/daemon/reset_restore_token_test.go diff --git a/README.md b/README.md index b986a30..b4c208a 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,7 @@ doubletake-ctl status doubletake-ctl connect 192.168.1.77 doubletake-ctl connect 192.168.1.133 doubletake-ctl disconnect 192.168.1.77 +doubletake-ctl reset-restore-token 192.168.1.133 doubletake-ctl disconnect ``` @@ -399,6 +400,7 @@ doubletake-ctl devices doubletake-ctl connect [target] [PIN-or-password] doubletake-ctl pin doubletake-ctl disconnect [target] +doubletake-ctl reset-restore-token doubletake-ctl mute [target] doubletake-ctl unmute [target] ``` @@ -406,6 +408,11 @@ doubletake-ctl unmute [target] - `disconnect` without a target stops all active streams. - `disconnect ` stops only that receiver. - `mute`/`unmute` can operate globally or per target. +- `reset-restore-token ` stops one fully streaming receiver, clears only + its saved Wayland portal restore token, and reconnects it on the same IP and + port. Pairing credentials and other streams are unchanged. The command rejects + targets sharing a capture group; disconnect those peers first so the old portal + source can be stopped before a replacement is authorized. - `pin` retains its historical command name, but submits whichever credential the daemon requests: an on-screen PIN or a configured password. It is targetless and therefore requires exactly one waiting receiver; use diff --git a/cmd/doubletake-ctl/main.go b/cmd/doubletake-ctl/main.go index f60f236..a3c2e40 100644 --- a/cmd/doubletake-ctl/main.go +++ b/cmd/doubletake-ctl/main.go @@ -61,6 +61,12 @@ func main() { } else { resp, err = client.Disconnect() } + case "reset-restore-token": + if len(args) != 2 { + fmt.Fprintln(os.Stderr, "Usage: doubletake-ctl reset-restore-token ") + os.Exit(1) + } + resp, err = client.ResetRestoreToken(args[1]) case "mute": if len(args) >= 2 { resp, err = client.MuteTarget(args[1]) @@ -94,5 +100,5 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, "Usage: doubletake-ctl [-socket path] [args]\n\nCommands:\n status Show daemon state and all active streams\n discover Discover AirPlay devices on the network\n devices List cached discovered devices\n connect [target] [PIN-or-password] Start mirroring (to target IP, or first free device)\n pin Submit pairing credentials for a waiting device\n disconnect [target] Stop mirroring (all streams, or only the given IP)\n mute [target] Mute mirrored audio (all streams, or only the given IP)\n unmute [target] Unmute mirrored audio (all streams, or only the given IP)\n\nFlags:\n -socket path Override daemon socket path (default: %s)\n", daemon.DefaultSocketPath()) + fmt.Fprintf(os.Stderr, "Usage: doubletake-ctl [-socket path] [args]\n\nCommands:\n status Show daemon state and all active streams\n discover Discover AirPlay devices on the network\n devices List cached discovered devices\n connect [target] [PIN-or-password] Start mirroring (to target IP, or first free device)\n pin Submit pairing credentials for a waiting device\n disconnect [target] Stop mirroring (all streams, or only the given IP)\n reset-restore-token Clear one Wayland restore token and reconnect that target\n mute [target] Mute mirrored audio (all streams, or only the given IP)\n unmute [target] Unmute mirrored audio (all streams, or only the given IP)\n\nFlags:\n -socket path Override daemon socket path (default: %s)\n", daemon.DefaultSocketPath()) } diff --git a/internal/airplay/credentials.go b/internal/airplay/credentials.go index c07a8ce..5f4fb38 100644 --- a/internal/airplay/credentials.go +++ b/internal/airplay/credentials.go @@ -193,6 +193,24 @@ func (cs *CredentialStore) SaveRestoreToken(deviceID, restoreToken string) error return cs.backend.Save(deviceID, creds) } +// ClearRestoreToken removes only the Wayland screencast restore token for a +// device. Pairing credentials and all other device entries are preserved. +func (cs *CredentialStore) ClearRestoreToken(deviceID string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + creds, err := cs.backend.Lookup(deviceID) + if err != nil { + return err + } + if creds == nil || creds.RestoreToken == "" { + return nil + } + updated := *creds + updated.RestoreToken = "" + return cs.backend.Save(deviceID, &updated) +} + // fileBackend stores credentials as a JSON file on disk. type fileBackend struct { path string diff --git a/internal/airplay/credentials_clear_test.go b/internal/airplay/credentials_clear_test.go new file mode 100644 index 0000000..5831b2c --- /dev/null +++ b/internal/airplay/credentials_clear_test.go @@ -0,0 +1,86 @@ +package airplay + +import ( + "crypto/ed25519" + "crypto/rand" + "path/filepath" + "testing" +) + +func TestCredentialStoreClearRestoreTokenPreservesPairingAndOtherDevices(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + store, err := NewCredentialStore(path) + if err != nil { + t.Fatalf("NewCredentialStore: %v", err) + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + if err := store.SavePairing("device-1", "pair-1", pub, priv, PairingProtocolHAP); err != nil { + t.Fatalf("SavePairing: %v", err) + } + if err := store.SaveRestoreToken("device-1", "restore-1"); err != nil { + t.Fatalf("SaveRestoreToken device-1: %v", err) + } + if err := store.SaveRestoreToken("device-2", "restore-2"); err != nil { + t.Fatalf("SaveRestoreToken device-2: %v", err) + } + + if err := store.ClearRestoreToken("device-1"); err != nil { + t.Fatalf("ClearRestoreToken: %v", err) + } + + reloaded, err := NewCredentialStore(path) + if err != nil { + t.Fatalf("reload credential store: %v", err) + } + cleared := reloaded.Lookup("device-1") + if cleared == nil || !cleared.HasPairingCredentials() { + t.Fatal("clearing the restore token removed pairing credentials") + } + if cleared.PairingID != "pair-1" || cleared.PairingProtocol != PairingProtocolHAP { + t.Fatalf("pairing metadata changed: %+v", cleared) + } + if cleared.RestoreToken != "" { + t.Fatalf("restore token = %q, want empty", cleared.RestoreToken) + } + other := reloaded.Lookup("device-2") + if other == nil || other.RestoreToken != "restore-2" { + t.Fatalf("other device changed: %+v", other) + } +} + +type recordingCredentialBackend struct { + devices map[string]*SavedCredentials + saves []string +} + +func (b *recordingCredentialBackend) Lookup(deviceID string) (*SavedCredentials, error) { + return b.devices[deviceID], nil +} + +func (b *recordingCredentialBackend) Save(deviceID string, creds *SavedCredentials) error { + b.devices[deviceID] = creds + b.saves = append(b.saves, deviceID) + return nil +} + +func TestCredentialStoreClearRestoreTokenUsesBackendWithoutDeletingEntry(t *testing.T) { + backend := &recordingCredentialBackend{devices: map[string]*SavedCredentials{ + "device-1": {PairingID: "pair-1", RestoreToken: "restore-1"}, + }} + store := NewCredentialStoreWithBackend(backend) + + if err := store.ClearRestoreToken("device-1"); err != nil { + t.Fatalf("ClearRestoreToken: %v", err) + } + + if len(backend.saves) != 1 || backend.saves[0] != "device-1" { + t.Fatalf("backend saves = %v, want [device-1]", backend.saves) + } + creds := backend.devices["device-1"] + if creds == nil || creds.PairingID != "pair-1" || creds.RestoreToken != "" { + t.Fatalf("backend credentials after clear = %+v", creds) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3d9d669..9a31669 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -204,6 +204,7 @@ type activeStream struct { device string // friendly name deviceIP string deviceID string + port int state State audioMuted bool session *airplay.MirrorSession @@ -548,6 +549,8 @@ func (d *Daemon) handleRequest(req Request) Response { return d.handleConnect(req) case "disconnect": return d.handleDisconnect(req) + case "reset-restore-token": + return d.handleResetRestoreToken(req) case "mute": return d.handleSetMute(req, true) case "unmute": @@ -783,6 +786,7 @@ func (d *Daemon) handleConnect(req Request) Response { connCtx, cancel := context.WithCancel(context.Background()) entry := &activeStream{ deviceIP: target, + port: port, state: StateConnecting, cancelFn: cancel, credentialCh: make(chan string, 1), diff --git a/internal/daemon/daemonclient/client.go b/internal/daemon/daemonclient/client.go index 2ce7d23..47f8bf5 100644 --- a/internal/daemon/daemonclient/client.go +++ b/internal/daemon/daemonclient/client.go @@ -54,6 +54,12 @@ func (c *Client) DisconnectTarget(target string) (*daemon.Response, error) { return c.send(daemon.Request{Cmd: "disconnect", Target: target}) } +// ResetRestoreToken clears one active receiver's Wayland restore token and +// reconnects that same target. +func (c *Client) ResetRestoreToken(target string) (*daemon.Response, error) { + return c.send(daemon.Request{Cmd: "reset-restore-token", Target: target}) +} + // Mute mutes mirrored audio on all active sessions. func (c *Client) Mute() (*daemon.Response, error) { return c.send(daemon.Request{Cmd: "mute"}) diff --git a/internal/daemon/daemonclient/client_test.go b/internal/daemon/daemonclient/client_test.go new file mode 100644 index 0000000..cfa8da9 --- /dev/null +++ b/internal/daemon/daemonclient/client_test.go @@ -0,0 +1,52 @@ +package daemonclient + +import ( + "encoding/json" + "net" + "path/filepath" + "testing" + "time" + + "doubletake/internal/daemon" +) + +func TestClientResetRestoreTokenSendsTargetedCommand(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "doubletake.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + requestCh := make(chan daemon.Request, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + var request daemon.Request + if json.NewDecoder(conn).Decode(&request) != nil { + return + } + requestCh <- request + _ = json.NewEncoder(conn).Encode(daemon.Response{OK: true, State: daemon.StateConnecting}) + }() + client := New(socketPath) + + response, err := client.ResetRestoreToken("192.0.2.10") + + if err != nil { + t.Fatalf("ResetRestoreToken: %v", err) + } + if response == nil || !response.OK { + t.Fatalf("response = %+v", response) + } + select { + case request := <-requestCh: + if request.Cmd != "reset-restore-token" || request.Target != "192.0.2.10" { + t.Fatalf("request = %+v", request) + } + case <-time.After(time.Second): + t.Fatal("client did not send reset request") + } +} diff --git a/internal/daemon/reset_restore_token.go b/internal/daemon/reset_restore_token.go new file mode 100644 index 0000000..77c6517 --- /dev/null +++ b/internal/daemon/reset_restore_token.go @@ -0,0 +1,92 @@ +package daemon + +import "context" + +func (d *Daemon) handleResetRestoreToken(req Request) Response { + d.mu.Lock() + if req.Target == "" { + response := Response{OK: false, State: d.overallStateLocked(), Error: "reset-restore-token requires a target IP"} + d.mu.Unlock() + return response + } + entry, ok := d.streams[req.Target] + if !ok { + response := Response{OK: false, State: d.overallStateLocked(), Error: "no active stream to " + req.Target} + d.mu.Unlock() + return response + } + if entry.state != StateStreaming { + response := Response{OK: false, State: d.overallStateLocked(), Error: "restore token can be reset only for a fully streaming target: " + req.Target} + d.mu.Unlock() + return response + } + if entry.captureGroup == nil || d.captureGroups[entry.captureGroup.key] != entry.captureGroup { + response := Response{OK: false, State: d.overallStateLocked(), Error: "active target has no owned capture group: " + req.Target} + d.mu.Unlock() + return response + } + for target, other := range d.streams { + if target != req.Target && other.captureGroup == entry.captureGroup { + response := Response{OK: false, State: d.overallStateLocked(), Error: "target uses a shared capture group; disconnect its peers before resetting the restore token"} + d.mu.Unlock() + return response + } + } + if entry.deviceID == "" || entry.port == 0 { + response := Response{OK: false, State: d.overallStateLocked(), Error: "active target is missing receiver identity or port: " + req.Target} + d.mu.Unlock() + return response + } + + target := entry.deviceIP + deviceID := entry.deviceID + port := entry.port + cleanup := d.detachStreamLocked(target) + connCtx, cancel := context.WithCancel(context.Background()) + replacement := &activeStream{ + deviceIP: target, + deviceID: deviceID, + port: port, + state: StateConnecting, + cancelFn: cancel, + credentialCh: make(chan string, 1), + } + d.clearLastErrorForTargetLocked(target) + d.streams[target] = replacement + // Reserve the target and register the replacement worker before unlocking. + // Shutdown can detach the reservation, but cannot finish Wait until this + // reset either abandons it or hands ownership to connectAndStream. + d.streamWorkers.Add(1) + d.mu.Unlock() + + cleanup.run() + + d.mu.Lock() + if d.shuttingDown || d.streams[target] != replacement { + if d.streams[target] == replacement { + abandoned := d.detachStreamLocked(target) + d.mu.Unlock() + abandoned.run() + } else { + d.mu.Unlock() + } + d.streamWorkers.Done() + return Response{OK: false, State: StateIdle, Error: "daemon is shutting down"} + } + if err := d.credStore.ClearRestoreToken(deviceID); err != nil { + abandoned := d.detachStreamLocked(target) + state := d.overallStateLocked() + d.mu.Unlock() + abandoned.run() + d.streamWorkers.Done() + return Response{OK: false, State: state, Error: "clear restore token: " + err.Error()} + } + state := d.overallStateLocked() + d.mu.Unlock() + + go func() { + defer d.streamWorkers.Done() + d.connectAndStream(connCtx, replacement, target, port, "") + }() + return Response{OK: true, State: state, Device: target, DeviceIP: target} +} diff --git a/internal/daemon/reset_restore_token_test.go b/internal/daemon/reset_restore_token_test.go new file mode 100644 index 0000000..4848142 --- /dev/null +++ b/internal/daemon/reset_restore_token_test.go @@ -0,0 +1,161 @@ +package daemon + +import ( + "context" + "net" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestResetRestoreTokenRejectsMissingUnknownAndNonStreamingTargetsWithoutMutation(t *testing.T) { + for _, test := range []struct { + name string + target string + state State + }{ + {name: "missing target"}, + {name: "unknown target", target: "192.0.2.99"}, + {name: "connecting target", target: "192.0.2.10", state: StateConnecting}, + {name: "credential-waiting target", target: "192.0.2.10", state: StatePINRequired}, + } { + t.Run(test.name, func(t *testing.T) { + d, err := New(Config{CredFile: filepath.Join(t.TempDir(), "credentials.json")}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := d.credStore.SaveRestoreToken("device-1", "restore-1"); err != nil { + t.Fatalf("SaveRestoreToken: %v", err) + } + entry := &activeStream{deviceIP: "192.0.2.10", deviceID: "device-1", state: test.state} + if test.state != "" { + d.streams[entry.deviceIP] = entry + } + + response := d.handleRequest(Request{Cmd: "reset-restore-token", Target: test.target}) + + if response.OK { + t.Fatalf("reset unexpectedly succeeded: %+v", response) + } + if d.streams[entry.deviceIP] != entry && test.state != "" { + t.Fatal("rejected reset detached the target") + } + creds := d.credStore.Lookup("device-1") + if creds == nil || creds.RestoreToken != "restore-1" { + t.Fatalf("rejected reset mutated credentials: %+v", creds) + } + }) + } +} + +func TestResetRestoreTokenRejectsSharedCaptureGroupWithoutMutation(t *testing.T) { + d, err := New(Config{CredFile: filepath.Join(t.TempDir(), "credentials.json")}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := d.credStore.SaveRestoreToken("device-1", "restore-1"); err != nil { + t.Fatalf("SaveRestoreToken: %v", err) + } + group := &videoCaptureGroup{key: normalizedVideoCaptureKey(1920, 1080)} + target := &activeStream{deviceIP: "192.0.2.10", deviceID: "device-1", state: StateStreaming, captureGroup: group} + peer := &activeStream{deviceIP: "192.0.2.11", deviceID: "device-2", state: StateStreaming, captureGroup: group} + d.streams[target.deviceIP] = target + d.streams[peer.deviceIP] = peer + d.captureGroups[group.key] = group + + response := d.handleRequest(Request{Cmd: "reset-restore-token", Target: target.deviceIP}) + + if response.OK || !strings.Contains(response.Error, "shared capture group") { + t.Fatalf("shared-group reset response = %+v", response) + } + if d.streams[target.deviceIP] != target || d.streams[peer.deviceIP] != peer || d.captureGroups[group.key] != group { + t.Fatal("shared-group rejection changed active stream state") + } + creds := d.credStore.Lookup("device-1") + if creds == nil || creds.RestoreToken != "restore-1" { + t.Fatalf("shared-group rejection mutated credentials: %+v", creds) + } +} + +func TestResetRestoreTokenClearsExclusiveTargetAndReconnectsActualPort(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + accepted := make(chan net.Conn, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr == nil { + accepted <- conn + } + }() + + d, err := New(Config{CredFile: filepath.Join(t.TempDir(), "credentials.json")}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer d.Shutdown() + if err := d.credStore.SaveRestoreToken("device-1", "restore-1"); err != nil { + t.Fatalf("SaveRestoreToken: %v", err) + } + address := listener.Addr().(*net.TCPAddr) + group := &videoCaptureGroup{key: normalizedVideoCaptureKey(1920, 1080)} + oldContext, cancelOld := context.WithCancel(context.Background()) + old := &activeStream{ + deviceIP: address.IP.String(), + deviceID: "device-1", + state: StateStreaming, + port: address.Port, + captureGroup: group, + cancelFn: cancelOld, + } + independentGroup := &videoCaptureGroup{key: normalizedVideoCaptureKey(1280, 720)} + independent := &activeStream{ + deviceIP: "192.0.2.20", + deviceID: "device-2", + state: StateStreaming, + captureGroup: independentGroup, + } + d.streams[old.deviceIP] = old + d.streams[independent.deviceIP] = independent + d.captureGroups[group.key] = group + d.captureGroups[independentGroup.key] = independentGroup + + response := d.handleRequest(Request{Cmd: "reset-restore-token", Target: old.deviceIP}) + + if !response.OK { + t.Fatalf("reset response = %+v", response) + } + creds := d.credStore.Lookup("device-1") + if creds == nil || creds.RestoreToken != "" { + t.Fatalf("restore token was not cleared: %+v", creds) + } + select { + case <-oldContext.Done(): + default: + t.Fatal("old stream cleanup did not finish before reset returned") + } + d.mu.Lock() + replacement := d.streams[old.deviceIP] + groupStillPresent := d.captureGroups[group.key] != nil + independentPreserved := d.streams[independent.deviceIP] == independent && + d.captureGroups[independentGroup.key] == independentGroup + d.mu.Unlock() + if replacement == nil || replacement == old || replacement.port != address.Port { + t.Fatalf("replacement stream = %+v, want new entry on port %d", replacement, address.Port) + } + if groupStillPresent { + t.Fatal("exclusive old capture group remained active") + } + if !independentPreserved { + t.Fatal("reset changed an independent stream or capture group") + } + select { + case conn := <-accepted: + defer conn.Close() + case <-time.After(3 * time.Second): + t.Fatal("reset did not reconnect to the target's actual port") + } +} diff --git a/man/man1/doubletake-ctl.1 b/man/man1/doubletake-ctl.1 index 99839c9..8bdb270 100644 --- a/man/man1/doubletake-ctl.1 +++ b/man/man1/doubletake-ctl.1 @@ -29,6 +29,12 @@ to select one explicitly. .TP .B disconnect [\fITARGET\fR] Disconnect from an Apple TV. If no target is specified, disconnects all active connections +.TP +.B reset-restore-token \fITARGET\fR +Stop one fully streaming target, clear only its saved Wayland portal restore +token, and reconnect the same IP and port. Pairing credentials and other streams +are preserved. The command refuses a target whose capture group is shared; first +disconnect the peers using that group so the old portal source can be stopped. .SH EXAMPLES .TP Check daemon status: @@ -46,6 +52,9 @@ Disconnect from a specific Apple TV: .TP Disconnect all connections: .B doubletake-ctl disconnect +.TP +Clear one receiver's Wayland restore token and reconnect it: +.B doubletake-ctl reset-restore-token 192.168.1.77 .SH USAGE First, start the doubletake daemon in a separate terminal or background: .IP From 3345fb1cb8f229ddfa9b2dd978d7261722814f1a Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 18:09:41 +0900 Subject: [PATCH 02/15] Harden restore-token reset lifecycle Reserve the active capture generation while credential I/O runs outside the daemon mutex, keep the original stream intact on persistence failure, reject peer joins during reset, and report disconnect or shutdown races accurately. Co-authored-by: minpeter Generated with [OpenAI Codex](https://openai.com/codex/) --- internal/airplay/credentials.go | 11 +- internal/airplay/credentials_clear_test.go | 27 ++ internal/daemon/daemon.go | 15 + internal/daemon/reset_restore_token.go | 104 ++++-- internal/daemon/reset_restore_token_test.go | 353 ++++++++++++++++++++ 5 files changed, 485 insertions(+), 25 deletions(-) diff --git a/internal/airplay/credentials.go b/internal/airplay/credentials.go index 5f4fb38..ace1d61 100644 --- a/internal/airplay/credentials.go +++ b/internal/airplay/credentials.go @@ -240,8 +240,17 @@ func (fb *fileBackend) Lookup(deviceID string) (*SavedCredentials, error) { } func (fb *fileBackend) Save(deviceID string, creds *SavedCredentials) error { + previous, existed := fb.devices[deviceID] fb.devices[deviceID] = creds - return fb.persist() + if err := fb.persist(); err != nil { + if existed { + fb.devices[deviceID] = previous + } else { + delete(fb.devices, deviceID) + } + return err + } + return nil } func (fb *fileBackend) persist() error { diff --git a/internal/airplay/credentials_clear_test.go b/internal/airplay/credentials_clear_test.go index 5831b2c..9a1c6ee 100644 --- a/internal/airplay/credentials_clear_test.go +++ b/internal/airplay/credentials_clear_test.go @@ -3,6 +3,7 @@ package airplay import ( "crypto/ed25519" "crypto/rand" + "os" "path/filepath" "testing" ) @@ -66,6 +67,32 @@ func (b *recordingCredentialBackend) Save(deviceID string, creds *SavedCredentia return nil } +func TestCredentialStoreClearRestoreTokenRollsBackFileBackendAfterPersistFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + store, err := NewCredentialStore(path) + if err != nil { + t.Fatalf("NewCredentialStore: %v", err) + } + if err := store.SaveRestoreToken("device-1", "restore-1"); err != nil { + t.Fatalf("SaveRestoreToken: %v", err) + } + + blockedParent := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blockedParent, []byte("block mkdir"), 0600); err != nil { + t.Fatalf("create blocked parent: %v", err) + } + backend := store.backend.(*fileBackend) + backend.path = filepath.Join(blockedParent, "credentials.json") + + if err := store.ClearRestoreToken("device-1"); err == nil { + t.Fatal("ClearRestoreToken unexpectedly succeeded with an invalid credential path") + } + creds := store.Lookup("device-1") + if creds == nil || creds.RestoreToken != "restore-1" { + t.Fatalf("failed persistence changed in-memory credentials: %+v", creds) + } +} + func TestCredentialStoreClearRestoreTokenUsesBackendWithoutDeletingEntry(t *testing.T) { backend := &recordingCredentialBackend{devices: map[string]*SavedCredentials{ "device-1": {PairingID: "pair-1", RestoreToken: "restore-1"}, diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9a31669..f0f9b4f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -234,8 +234,11 @@ type videoCaptureGroup struct { capture *airplay.ScreenCapture minimumVideoLead time.Duration cancel context.CancelFunc + resetReservedBy *activeStream } +var errCaptureGroupResetReserved = errors.New("capture group is reserved for restore-token reset") + // daemonCleanup owns resources detached from the daemon's state maps. Building // a cleanup plan while holding d.mu makes the state change atomic; running it // afterwards keeps cancellation, pipe closure, RTSP teardown, socket closure, @@ -265,6 +268,9 @@ func (cleanup *daemonCleanup) addStream(entry *activeStream) { cleanup.clients = append(cleanup.clients, entry.client) entry.client = nil } + if entry.captureGroup != nil && entry.captureGroup.resetReservedBy == entry { + entry.captureGroup.resetReservedBy = nil + } entry.captureGroup = nil } @@ -278,6 +284,7 @@ func (cleanup *daemonCleanup) addCaptureGroup(group *videoCaptureGroup) { group.capture = nil } group.broadcast = nil + group.resetReservedBy = nil } // run may block and therefore must never be called with d.mu held. Cancel all @@ -1347,6 +1354,10 @@ func (d *Daemon) getOrStartPreparedCaptureGroup(ctx context.Context, entry *acti d.captureGroups = make(map[videoCaptureKey]*videoCaptureGroup) } if group := d.captureGroups[key]; group != nil { + if group.resetReservedBy != nil && group.resetReservedBy != entry { + d.mu.Unlock() + return nil, 0, fmt.Errorf("%w: %dx%d", errCaptureGroupResetReserved, key.maxWidth, key.maxHeight) + } entry.captureGroup = group broadcast := group.broadcast d.mu.Unlock() @@ -1453,6 +1464,10 @@ func (d *Daemon) getOrStartCaptureGroup(entry *activeStream, restoreToken, devic d.captureGroups = make(map[videoCaptureKey]*videoCaptureGroup) } if group := d.captureGroups[key]; group != nil { + if group.resetReservedBy != nil && group.resetReservedBy != entry { + d.mu.Unlock() + return nil, fmt.Errorf("%w: %dx%d", errCaptureGroupResetReserved, key.maxWidth, key.maxHeight) + } entry.captureGroup = group broadcast := group.broadcast d.mu.Unlock() diff --git a/internal/daemon/reset_restore_token.go b/internal/daemon/reset_restore_token.go index 77c6517..eeb5c3c 100644 --- a/internal/daemon/reset_restore_token.go +++ b/internal/daemon/reset_restore_token.go @@ -9,6 +9,10 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { d.mu.Unlock() return response } + if d.shuttingDown { + d.mu.Unlock() + return Response{OK: false, State: StateIdle, Error: "daemon is shutting down"} + } entry, ok := d.streams[req.Target] if !ok { response := Response{OK: false, State: d.overallStateLocked(), Error: "no active stream to " + req.Target} @@ -20,19 +24,25 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { d.mu.Unlock() return response } - if entry.captureGroup == nil || d.captureGroups[entry.captureGroup.key] != entry.captureGroup { + group := entry.captureGroup + if group == nil || d.captureGroups[group.key] != group { response := Response{OK: false, State: d.overallStateLocked(), Error: "active target has no owned capture group: " + req.Target} d.mu.Unlock() return response } + if group.resetReservedBy != nil { + response := Response{OK: false, State: d.overallStateLocked(), Error: "restore token reset is already in progress for " + req.Target} + d.mu.Unlock() + return response + } for target, other := range d.streams { - if target != req.Target && other.captureGroup == entry.captureGroup { + if target != req.Target && other.captureGroup == group { response := Response{OK: false, State: d.overallStateLocked(), Error: "target uses a shared capture group; disconnect its peers before resetting the restore token"} d.mu.Unlock() return response } } - if entry.deviceID == "" || entry.port == 0 { + if entry.deviceIP != req.Target || entry.deviceID == "" || entry.port == 0 { response := Response{OK: false, State: d.overallStateLocked(), Error: "active target is missing receiver identity or port: " + req.Target} d.mu.Unlock() return response @@ -41,7 +51,37 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { target := entry.deviceIP deviceID := entry.deviceID port := entry.port - cleanup := d.detachStreamLocked(target) + group.resetReservedBy = entry + // Register the reset before releasing d.mu. Shutdown may detach the original + // stream while credential I/O is in progress, but it cannot finish Wait until + // this request either abandons the reservation or starts the replacement. + d.streamWorkers.Add(1) + d.mu.Unlock() + + clearErr := d.credStore.ClearRestoreToken(deviceID) + + d.mu.Lock() + reservationCurrent := d.restoreTokenResetReservationCurrentLocked(target, deviceID, port, entry, group) + if group.resetReservedBy == entry { + group.resetReservedBy = nil + } + state := d.overallStateLocked() + if d.shuttingDown { + d.mu.Unlock() + d.streamWorkers.Done() + return Response{OK: false, State: state, Error: "daemon is shutting down"} + } + if !reservationCurrent { + d.mu.Unlock() + d.streamWorkers.Done() + return Response{OK: false, State: state, Error: "restore token reset was canceled for " + target} + } + if clearErr != nil { + d.mu.Unlock() + d.streamWorkers.Done() + return Response{OK: false, State: state, Error: "clear restore token: " + clearErr.Error()} + } + connCtx, cancel := context.WithCancel(context.Background()) replacement := &activeStream{ deviceIP: target, @@ -51,37 +91,35 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { cancelFn: cancel, credentialCh: make(chan string, 1), } - d.clearLastErrorForTargetLocked(target) + cleanup := d.detachStreamLocked(target) d.streams[target] = replacement - // Reserve the target and register the replacement worker before unlocking. - // Shutdown can detach the reservation, but cannot finish Wait until this - // reset either abandons it or hands ownership to connectAndStream. - d.streamWorkers.Add(1) d.mu.Unlock() cleanup.run() + // Cleanup can block, so disconnect or shutdown may remove the replacement + // reservation before its worker starts. Only detach the exact generation we + // published and report the daemon's actual aggregate state. d.mu.Lock() - if d.shuttingDown || d.streams[target] != replacement { - if d.streams[target] == replacement { - abandoned := d.detachStreamLocked(target) - d.mu.Unlock() - abandoned.run() - } else { - d.mu.Unlock() + replacementCurrent := d.streams[target] == replacement + shuttingDown := d.shuttingDown + if shuttingDown || !replacementCurrent { + abandoned := daemonCleanup{} + if replacementCurrent { + abandoned = d.detachStreamLocked(target) } - d.streamWorkers.Done() - return Response{OK: false, State: StateIdle, Error: "daemon is shutting down"} - } - if err := d.credStore.ClearRestoreToken(deviceID); err != nil { - abandoned := d.detachStreamLocked(target) - state := d.overallStateLocked() + state = d.overallStateLocked() d.mu.Unlock() abandoned.run() + cancel() d.streamWorkers.Done() - return Response{OK: false, State: state, Error: "clear restore token: " + err.Error()} + if shuttingDown { + return Response{OK: false, State: state, Error: "daemon is shutting down"} + } + return Response{OK: false, State: state, Error: "restore token reset was canceled for " + target} } - state := d.overallStateLocked() + d.clearLastErrorForTargetLocked(target) + state = d.overallStateLocked() d.mu.Unlock() go func() { @@ -90,3 +128,21 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { }() return Response{OK: true, State: state, Device: target, DeviceIP: target} } + +// restoreTokenResetReservationCurrentLocked reports whether the exact stream +// and exclusive capture generation reserved before credential I/O are still +// current. Must be called with d.mu held. +func (d *Daemon) restoreTokenResetReservationCurrentLocked(target, deviceID string, port int, entry *activeStream, group *videoCaptureGroup) bool { + if d.streams[target] != entry || entry.state != StateStreaming || + entry.deviceIP != target || entry.deviceID != deviceID || entry.port != port || + entry.captureGroup != group || d.captureGroups[group.key] != group || + group.resetReservedBy != entry { + return false + } + for otherTarget, other := range d.streams { + if otherTarget != target && other.captureGroup == group { + return false + } + } + return true +} diff --git a/internal/daemon/reset_restore_token_test.go b/internal/daemon/reset_restore_token_test.go index 4848142..d4c5608 100644 --- a/internal/daemon/reset_restore_token_test.go +++ b/internal/daemon/reset_restore_token_test.go @@ -2,11 +2,15 @@ package daemon import ( "context" + "errors" "net" "path/filepath" "strings" + "sync" "testing" "time" + + "doubletake/internal/airplay" ) func TestResetRestoreTokenRejectsMissingUnknownAndNonStreamingTargetsWithoutMutation(t *testing.T) { @@ -78,6 +82,269 @@ func TestResetRestoreTokenRejectsSharedCaptureGroupWithoutMutation(t *testing.T) } } +func TestResetRestoreTokenKeepsDaemonResponsiveDuringCredentialSave(t *testing.T) { + saveStarted := make(chan struct{}) + saveRelease := make(chan struct{}) + var releaseOnce sync.Once + releaseSave := func() { releaseOnce.Do(func() { close(saveRelease) }) } + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{RestoreToken: "restore-1"}, + saveStarted: saveStarted, + saveRelease: saveRelease, + saveErr: errors.New("save failed"), + } + d, _, _, _ := newResetTestDaemon(t, backend) + defer d.Shutdown() + defer releaseSave() + + resetResponse := make(chan Response, 1) + go func() { + resetResponse <- d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + }() + waitForResetSignal(t, saveStarted, "credential save did not start") + + statusResponse := make(chan Response, 1) + go func() { + statusResponse <- d.handleStatus() + }() + select { + case response := <-statusResponse: + if response.State != StateStreaming { + t.Fatalf("status during credential save = %+v, want original stream", response) + } + case <-time.After(time.Second): + t.Fatal("status blocked behind credential backend I/O") + } + + releaseSave() + response := waitForResetResponse(t, resetResponse) + if response.OK || !strings.Contains(response.Error, "save failed") { + t.Fatalf("reset response = %+v, want credential save failure", response) + } +} + +func TestResetRestoreTokenCredentialFailureIsAtomic(t *testing.T) { + for _, test := range []struct { + name string + lookupErr error + saveErr error + }{ + {name: "lookup failure", lookupErr: errors.New("lookup failed")}, + {name: "save failure", saveErr: errors.New("save failed")}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{PairingID: "pair-1", RestoreToken: "restore-1"}, + lookupErr: test.lookupErr, + saveErr: test.saveErr, + } + d, entry, group, streamCtx := newResetTestDaemon(t, backend) + defer d.Shutdown() + d.lastError = "existing stream error" + d.lastErrorTarget = resetTestTarget + + response := d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + + if response.OK { + t.Fatalf("reset unexpectedly succeeded: %+v", response) + } + d.mu.Lock() + streamPreserved := d.streams[resetTestTarget] == entry + groupPreserved := d.captureGroups[group.key] == group && entry.captureGroup == group + errorPreserved := d.lastError == "existing stream error" && d.lastErrorTarget == resetTestTarget + d.mu.Unlock() + if !streamPreserved || !groupPreserved { + t.Fatalf("credential failure changed stream state: stream=%t group=%t", streamPreserved, groupPreserved) + } + if !errorPreserved { + t.Fatalf("credential failure changed last error: %q for %q", d.lastError, d.lastErrorTarget) + } + select { + case <-streamCtx.Done(): + t.Fatal("credential failure canceled the original stream") + default: + } + creds := d.credStore.Lookup("device-1") + if creds == nil || creds.RestoreToken != "restore-1" { + t.Fatalf("credential failure changed restore token: %+v", creds) + } + + peer := &activeStream{deviceIP: "192.0.2.11", state: StateConnecting} + d.mu.Lock() + d.streams[peer.deviceIP] = peer + d.mu.Unlock() + broadcast, _, err := d.getOrStartPreparedCaptureGroup(context.Background(), peer, nil, 1920, 1080, airplay.VideoCodecH264) + if err != nil || broadcast != group.broadcast { + t.Fatalf("capture reservation remained after failed reset: broadcast=%p err=%v", broadcast, err) + } + d.mu.Lock() + delete(d.streams, peer.deviceIP) + peer.captureGroup = nil + d.mu.Unlock() + }) + } +} + +func TestResetRestoreTokenReservationExcludesCaptureGroupJoin(t *testing.T) { + saveStarted := make(chan struct{}) + saveRelease := make(chan struct{}) + var releaseOnce sync.Once + releaseSave := func() { releaseOnce.Do(func() { close(saveRelease) }) } + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{RestoreToken: "restore-1"}, + saveStarted: saveStarted, + saveRelease: saveRelease, + saveErr: errors.New("save failed"), + } + d, _, _, _ := newResetTestDaemon(t, backend) + defer d.Shutdown() + defer releaseSave() + peer := &activeStream{deviceIP: "192.0.2.11", state: StateConnecting} + d.streams[peer.deviceIP] = peer + + resetResponse := make(chan Response, 1) + go func() { + resetResponse <- d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + }() + waitForResetSignal(t, saveStarted, "credential save did not start") + + type joinResult struct { + err error + } + joinResults := make(chan joinResult, 1) + go func() { + _, _, err := d.getOrStartPreparedCaptureGroup(context.Background(), peer, nil, 1920, 1080, airplay.VideoCodecH264) + joinResults <- joinResult{err: err} + }() + var result joinResult + select { + case result = <-joinResults: + case <-time.After(time.Second): + t.Fatal("peer capture join blocked behind credential backend I/O") + } + if result.err == nil || !strings.Contains(result.err.Error(), "reserved for restore-token reset") { + t.Fatalf("peer capture join error = %v, want reset reservation rejection", result.err) + } + d.mu.Lock() + joined := peer.captureGroup != nil + delete(d.streams, peer.deviceIP) + d.mu.Unlock() + if joined { + t.Fatal("peer joined capture group while restore-token reset was reserved") + } + + releaseSave() + response := waitForResetResponse(t, resetResponse) + if response.OK || !strings.Contains(response.Error, "save failed") { + t.Fatalf("reset response = %+v, want credential save failure", response) + } +} + +func TestResetRestoreTokenReservationMakesShutdownWait(t *testing.T) { + saveStarted := make(chan struct{}) + saveRelease := make(chan struct{}) + var releaseOnce sync.Once + releaseSave := func() { releaseOnce.Do(func() { close(saveRelease) }) } + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{RestoreToken: "restore-1"}, + saveStarted: saveStarted, + saveRelease: saveRelease, + } + d, entry, _, _ := newResetTestDaemon(t, backend) + defer releaseSave() + + cancelEvents := make(chan struct{}, 2) + originalCancel := entry.cancelFn + entry.cancelFn = func() { + originalCancel() + cancelEvents <- struct{}{} + } + resetResponse := make(chan Response, 1) + resetDone := make(chan struct{}) + go func() { + resetResponse <- d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + close(resetDone) + }() + defer func() { + releaseSave() + waitForResetSignal(t, resetDone, "reset did not finish during cleanup") + }() + waitForResetSignal(t, saveStarted, "credential save did not start") + select { + case <-cancelEvents: + // The old implementation canceled before credential I/O. Drain that event + // so only shutdown detachment can satisfy the wait below. + default: + } + + shutdownDone := make(chan struct{}) + go func() { + d.Shutdown() + close(shutdownDone) + }() + waitForResetSignal(t, cancelEvents, "shutdown did not detach the reserved stream") + select { + case <-shutdownDone: + t.Fatal("Shutdown returned while credential I/O was still blocked") + case <-time.After(100 * time.Millisecond): + } + + releaseSave() + response := waitForResetResponse(t, resetResponse) + if response.OK || !strings.Contains(response.Error, "shutting down") || response.State != StateIdle { + t.Fatalf("reset response during shutdown = %+v", response) + } + waitForResetSignal(t, shutdownDone, "Shutdown did not finish after reset released its reservation") +} + +func TestResetRestoreTokenConcurrentDisconnectReportsCancellationAndOverallState(t *testing.T) { + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{RestoreToken: "restore-1"}, + } + d, entry, _, _ := newResetTestDaemon(t, backend) + cleanupStarted := make(chan struct{}) + cleanupRelease := make(chan struct{}) + var releaseOnce sync.Once + releaseCleanup := func() { releaseOnce.Do(func() { close(cleanupRelease) }) } + originalCancel := entry.cancelFn + entry.cancelFn = func() { + close(cleanupStarted) + <-cleanupRelease + originalCancel() + } + independentGroup := &videoCaptureGroup{key: normalizedVideoCaptureKey(1280, 720)} + independent := &activeStream{ + deviceIP: "192.0.2.20", + deviceID: "device-2", + port: 7000, + state: StateStreaming, + captureGroup: independentGroup, + } + d.streams[independent.deviceIP] = independent + d.captureGroups[independentGroup.key] = independentGroup + defer d.Shutdown() + defer releaseCleanup() + + resetResponse := make(chan Response, 1) + go func() { + resetResponse <- d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + }() + waitForResetSignal(t, cleanupStarted, "old stream cleanup did not start") + + disconnect := d.handleDisconnect(Request{Cmd: "disconnect", Target: resetTestTarget}) + if !disconnect.OK || disconnect.State != StateStreaming { + t.Fatalf("concurrent disconnect response = %+v, want independent stream preserved", disconnect) + } + releaseCleanup() + response := waitForResetResponse(t, resetResponse) + if response.OK || !strings.Contains(response.Error, "canceled") { + t.Fatalf("reset response = %+v, want concurrent cancellation", response) + } + if strings.Contains(response.Error, "shutting down") || response.State != StateStreaming { + t.Fatalf("reset misreported concurrent disconnect: %+v", response) + } +} + func TestResetRestoreTokenClearsExclusiveTargetAndReconnectsActualPort(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -159,3 +426,89 @@ func TestResetRestoreTokenClearsExclusiveTargetAndReconnectsActualPort(t *testin t.Fatal("reset did not reconnect to the target's actual port") } } + +const resetTestTarget = "192.0.2.10" + +type controlledCredentialBackend struct { + credentials *airplay.SavedCredentials + lookupErr error + saveErr error + saveStarted chan struct{} + saveRelease <-chan struct{} +} + +func (b *controlledCredentialBackend) Lookup(string) (*airplay.SavedCredentials, error) { + if b.lookupErr != nil { + err := b.lookupErr + b.lookupErr = nil + return nil, err + } + if b.credentials == nil { + return nil, nil + } + credentials := *b.credentials + return &credentials, nil +} + +func (b *controlledCredentialBackend) Save(_ string, credentials *airplay.SavedCredentials) error { + if b.saveStarted != nil { + close(b.saveStarted) + } + if b.saveRelease != nil { + <-b.saveRelease + } + if b.saveErr != nil { + return b.saveErr + } + updated := *credentials + b.credentials = &updated + return nil +} + +func newResetTestDaemon(t *testing.T, backend airplay.CredentialBackend) (*Daemon, *activeStream, *videoCaptureGroup, context.Context) { + t.Helper() + d, err := New(Config{ + SocketPath: filepath.Join(t.TempDir(), "doubletake.sock"), + CredFile: filepath.Join(t.TempDir(), "credentials.json"), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + d.credStore = airplay.NewCredentialStoreWithBackend(backend) + group := &videoCaptureGroup{ + key: normalizedVideoCaptureKey(1920, 1080), + broadcast: airplay.NewBroadcastCapture(nil), + } + streamCtx, cancel := context.WithCancel(context.Background()) + entry := &activeStream{ + deviceIP: resetTestTarget, + deviceID: "device-1", + port: 7000, + state: StateStreaming, + captureGroup: group, + cancelFn: cancel, + } + d.streams[resetTestTarget] = entry + d.captureGroups[group.key] = group + return d, entry, group, streamCtx +} + +func waitForResetSignal(t *testing.T, signal <-chan struct{}, failure string) { + t.Helper() + select { + case <-signal: + case <-time.After(time.Second): + t.Fatal(failure) + } +} + +func waitForResetResponse(t *testing.T, responses <-chan Response) Response { + t.Helper() + select { + case response := <-responses: + return response + case <-time.After(time.Second): + t.Fatal("reset did not return") + return Response{} + } +} From 214c1777c6a896312be0064690b689d79f6ae554 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 18:09:58 +0900 Subject: [PATCH 03/15] Test restore-token reset CLI dispatch Exercise the public command through its real process boundary, verify the exact Unix-socket request, and reject invalid arity before daemon contact. Co-authored-by: minpeter Generated with [OpenAI Codex](https://openai.com/codex/) --- cmd/doubletake-ctl/main_test.go | 183 ++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 cmd/doubletake-ctl/main_test.go diff --git a/cmd/doubletake-ctl/main_test.go b/cmd/doubletake-ctl/main_test.go new file mode 100644 index 0000000..d288e2f --- /dev/null +++ b/cmd/doubletake-ctl/main_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +const cliHelperEnvironment = "DOUBLETAKE_CTL_TEST_HELPER" + +type controlFixtureResult struct { + request map[string]json.RawMessage + err error +} + +func TestResetRestoreTokenCLI(t *testing.T) { + listener, results := startControlFixture(t) + socketPath := listener.Addr().String() + + code, stdout, stderr := runCLIProcess(t, + "-socket", socketPath, + "reset-restore-token", "192.0.2.10", + ) + if code != 0 { + t.Fatalf("reset-restore-token exit code = %d, want 0; stdout=%q stderr=%q", code, stdout, stderr) + } + + result := awaitControlFixture(t, results) + if result.err != nil { + t.Fatalf("control fixture: %v", result.err) + } + if len(result.request) != 2 { + t.Fatalf("request fields = %v, want exactly cmd and target", result.request) + } + assertJSONStringField(t, result.request, "cmd", "reset-restore-token") + assertJSONStringField(t, result.request, "target", "192.0.2.10") + + if err := listener.Close(); err != nil { + t.Fatalf("close control fixture: %v", err) + } +} + +func TestResetRestoreTokenCLIRejectsInvalidArityWithoutContactingDaemon(t *testing.T) { + for _, test := range []struct { + name string + args []string + }{ + {name: "missing target", args: []string{"reset-restore-token"}}, + {name: "extra target", args: []string{"reset-restore-token", "192.0.2.10", "192.0.2.11"}}, + } { + t.Run(test.name, func(t *testing.T) { + listener, results := startControlFixture(t) + args := append([]string{"-socket", listener.Addr().String()}, test.args...) + + code, stdout, stderr := runCLIProcess(t, args...) + if code == 0 { + t.Fatalf("invalid reset exit code = 0, want nonzero; stdout=%q stderr=%q", stdout, stderr) + } + if err := listener.Close(); err != nil { + t.Fatalf("close control fixture: %v", err) + } + + result := awaitControlFixture(t, results) + if result.err == nil { + t.Fatalf("invalid reset contacted daemon with request %v", result.request) + } + if !errors.Is(result.err, net.ErrClosed) { + t.Fatalf("control fixture stopped with %v, want listener close", result.err) + } + }) + } +} + +func TestDoubletakeCtlHelperProcess(t *testing.T) { + if os.Getenv(cliHelperEnvironment) != "1" { + return + } + separator := -1 + for i, arg := range os.Args { + if arg == "--" { + separator = i + break + } + } + if separator < 0 { + os.Exit(2) + } + os.Args = append([]string{"doubletake-ctl"}, os.Args[separator+1:]...) + main() +} + +func startControlFixture(t *testing.T) (net.Listener, <-chan controlFixtureResult) { + t.Helper() + listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "doubletake.sock")) + if err != nil { + t.Fatalf("listen on control socket: %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + results := make(chan controlFixtureResult, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + results <- controlFixtureResult{err: err} + return + } + defer conn.Close() + + var request map[string]json.RawMessage + if err := json.NewDecoder(conn).Decode(&request); err != nil { + results <- controlFixtureResult{err: err} + return + } + if err := json.NewEncoder(conn).Encode(map[string]any{"ok": true, "state": "connecting"}); err != nil { + results <- controlFixtureResult{err: err} + return + } + results <- controlFixtureResult{request: request} + }() + return listener, results +} + +func runCLIProcess(t *testing.T, args ...string) (int, string, string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + commandArgs := []string{"-test.run=^TestDoubletakeCtlHelperProcess$", "--"} + commandArgs = append(commandArgs, args...) + cmd := exec.CommandContext(ctx, os.Args[0], commandArgs...) + cmd.Env = append(os.Environ(), cliHelperEnvironment+"=1") + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if ctx.Err() != nil { + t.Fatalf("CLI process did not complete: %v", ctx.Err()) + } + if err == nil { + return 0, stdout.String(), stderr.String() + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("run CLI process: %v", err) + } + return exitErr.ExitCode(), stdout.String(), stderr.String() +} + +func awaitControlFixture(t *testing.T, results <-chan controlFixtureResult) controlFixtureResult { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + select { + case result := <-results: + return result + case <-ctx.Done(): + t.Fatalf("control fixture did not finish: %v", ctx.Err()) + return controlFixtureResult{} + } +} + +func assertJSONStringField(t *testing.T, request map[string]json.RawMessage, field, want string) { + t.Helper() + raw, ok := request[field] + if !ok { + t.Fatalf("request is missing %q: %v", field, request) + } + var got string + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("decode request field %q: %v", field, err) + } + if got != want { + t.Fatalf("request field %q = %q, want %q", field, got, want) + } +} From 8b5596b3419d3ef37055719c1fc24d0f4d61b94a Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 19:13:39 +0900 Subject: [PATCH 04/15] Align reset command help text --- cmd/doubletake-ctl/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/doubletake-ctl/main.go b/cmd/doubletake-ctl/main.go index a3c2e40..b132424 100644 --- a/cmd/doubletake-ctl/main.go +++ b/cmd/doubletake-ctl/main.go @@ -100,5 +100,5 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, "Usage: doubletake-ctl [-socket path] [args]\n\nCommands:\n status Show daemon state and all active streams\n discover Discover AirPlay devices on the network\n devices List cached discovered devices\n connect [target] [PIN-or-password] Start mirroring (to target IP, or first free device)\n pin Submit pairing credentials for a waiting device\n disconnect [target] Stop mirroring (all streams, or only the given IP)\n reset-restore-token Clear one Wayland restore token and reconnect that target\n mute [target] Mute mirrored audio (all streams, or only the given IP)\n unmute [target] Unmute mirrored audio (all streams, or only the given IP)\n\nFlags:\n -socket path Override daemon socket path (default: %s)\n", daemon.DefaultSocketPath()) + fmt.Fprintf(os.Stderr, "Usage: doubletake-ctl [-socket path] [args]\n\nCommands:\n status Show daemon state and all active streams\n discover Discover AirPlay devices on the network\n devices List cached discovered devices\n connect [target] [PIN-or-password] Start mirroring (to target IP, or first free device)\n pin Submit pairing credentials for a waiting device\n disconnect [target] Stop mirroring (all streams, or only the given IP)\n reset-restore-token Clear one Wayland restore token and reconnect that target\n mute [target] Mute mirrored audio (all streams, or only the given IP)\n unmute [target] Unmute mirrored audio (all streams, or only the given IP)\n\nFlags:\n -socket path Override daemon socket path (default: %s)\n", daemon.DefaultSocketPath()) } From c6f5e63c0ec38e7a9272fb28282883780d01e666 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 20:41:42 +0900 Subject: [PATCH 05/15] Preserve Wayland DMA-BUF capture frames --- internal/airplay/capture.go | 44 ++++++++++++++++----- internal/airplay/capture_test.go | 65 ++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/internal/airplay/capture.go b/internal/airplay/capture.go index 5aaf46b..5a44d55 100644 --- a/internal/airplay/capture.go +++ b/internal/airplay/capture.go @@ -725,21 +725,45 @@ func frameIntervalMillis(fps int) int { return max(1, 1000/fps) } -func pipeWireVideoSourceStage(fd int, nodeID uint32, fps int) gstStage { - return gstStage{ +func pipeWireVideoSourceStage(fd int, nodeID uint32, fps int, copyPortalBuffers bool) gstStage { + stage := gstStage{ "pipewiresrc", fmt.Sprintf("fd=%d", fd), fmt.Sprintf("path=%d", nodeID), "do-timestamp=true", fmt.Sprintf("keepalive-time=%d", frameIntervalMillis(fps)), + } + if copyPortalBuffers { // The compositor and pipewiresrc's keepalive path both retain the latest // GstBuffer. With a small portal pool that can keep every PipeWire buffer // checked out and freeze screencopy. Copying here returns the portal buffer // as soon as pipewiresrc pulls it while downstream retains only the copy. - "always-copy=true", + stage = append(stage, "always-copy=true") + } + return stage +} + +func vaapiVideoImportStages() []gstStage { + // Force a fresh VA surface even when the portal's DMA-BUF already satisfies + // downstream caps, then download it into system memory. The forced-live + // compositor may retain its latest input; retaining the portal DMA-BUF would + // eventually exhaust PipeWire's pool and repeat one stale capture timestamp. + return []gstStage{ + {"vapostproc", "disable-passthrough=true"}, + // An unfeatured raw caps filter means system memory while leaving the + // pixel format negotiable, preserving 10-bit portal input for HEVC. + {"video/x-raw"}, } } +func waylandVideoInputStages(fd int, nodeID uint32, fps int, useVAAPI bool) (gstStage, []gstStage) { + source := pipeWireVideoSourceStage(fd, nodeID, fps, !useVAAPI) + if !useVAAPI { + return source, nil + } + return source, vaapiVideoImportStages() +} + func lowLatencyVideoQueueStage() gstStage { // Drop stale raw frames before encoding. Encoded P-frames may reference // earlier frames, so dropping them downstream would corrupt the codec chain. @@ -853,14 +877,14 @@ func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoder // The encoded dimensions are capped to the receiver's advertised display size // when available. The actual result is read back from the codec SPS downstream. const pwFdNum = 3 - source := pipeWireVideoSourceStage(pwFdNum, nodeID, fps) - hasCompositor := streamSize[0] > 0 && streamSize[1] > 0 && hasGstElement("compositor") - - var beforeConvert []gstStage - if hasGstElement("vapostproc") { - beforeConvert = append(beforeConvert, gstStage{"vapostproc"}) - } else { + hasVAAPIPostproc := hasGstElement("vapostproc") + // VA-API needs the portal's original DMA-BUF. pipewiresrc's always-copy path + // can turn DMA-BUF map failures into black fallback frames before vapostproc + // gets a chance to import them. The software path still copies immediately + // so a forced-live compositor cannot exhaust a small portal buffer pool. + source, beforeConvert := waylandVideoInputStages(pwFdNum, nodeID, fps, hasVAAPIPostproc) + if !hasVAAPIPostproc { log.Printf("[CAPTURE] vapostproc unavailable, using software conversion") } diff --git a/internal/airplay/capture_test.go b/internal/airplay/capture_test.go index 7f3ac73..a6111c4 100644 --- a/internal/airplay/capture_test.go +++ b/internal/airplay/capture_test.go @@ -361,8 +361,22 @@ func TestFrameIntervalMillis(t *testing.T) { } } -func TestPipeWireVideoSourceCopiesPortalBuffers(t *testing.T) { - got := pipeWireVideoSourceStage(3, 42, 30) +func TestPipeWireVideoSourcePreservesDMABuffersForVAAPI(t *testing.T) { + got := pipeWireVideoSourceStage(3, 42, 30, false) + want := gstStage{ + "pipewiresrc", + "fd=3", + "path=42", + "do-timestamp=true", + "keepalive-time=33", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("PipeWire VA-API source stage = %v, want %v", got, want) + } +} + +func TestPipeWireVideoSourceCopiesPortalBuffersForSoftwareConversion(t *testing.T) { + got := pipeWireVideoSourceStage(3, 42, 30, true) want := gstStage{ "pipewiresrc", "fd=3", @@ -372,7 +386,52 @@ func TestPipeWireVideoSourceCopiesPortalBuffers(t *testing.T) { "always-copy=true", } if !reflect.DeepEqual(got, want) { - t.Fatalf("PipeWire source stage = %v, want %v", got, want) + t.Fatalf("PipeWire software source stage = %v, want %v", got, want) + } +} + +func TestVAAPIPostprocCopiesPortalDMABufferToSystemMemory(t *testing.T) { + got := vaapiVideoImportStages() + want := []gstStage{ + {"vapostproc", "disable-passthrough=true"}, + {"video/x-raw"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("VA-API import stages = %v, want %v", got, want) + } +} + +func TestWaylandVideoInputStagesPreservePortalBufferOwnership(t *testing.T) { + for _, tt := range []struct { + name string + useVAAPI bool + wantSource gstStage + wantImports []gstStage + }{ + { + name: "VA-API imports before system-memory copy", + useVAAPI: true, + wantSource: gstStage{"pipewiresrc", "fd=3", "path=42", "do-timestamp=true", "keepalive-time=33"}, + wantImports: []gstStage{ + {"vapostproc", "disable-passthrough=true"}, + {"video/x-raw"}, + }, + }, + { + name: "software conversion copies portal buffer", + useVAAPI: false, + wantSource: gstStage{"pipewiresrc", "fd=3", "path=42", "do-timestamp=true", "keepalive-time=33", "always-copy=true"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + gotSource, gotImports := waylandVideoInputStages(3, 42, 30, tt.useVAAPI) + if !reflect.DeepEqual(gotSource, tt.wantSource) { + t.Fatalf("source stage = %v, want %v", gotSource, tt.wantSource) + } + if !reflect.DeepEqual(gotImports, tt.wantImports) { + t.Fatalf("import stages = %v, want %v", gotImports, tt.wantImports) + } + }) } } From f182ef35fa3a01195ab0a4e79b3ec5307d48623d Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 21:24:21 +0900 Subject: [PATCH 06/15] Restore direct VA-API DMA-BUF import --- internal/airplay/capture.go | 17 +++-------------- internal/airplay/capture_test.go | 18 ++++++------------ 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/internal/airplay/capture.go b/internal/airplay/capture.go index 5a44d55..75d5b86 100644 --- a/internal/airplay/capture.go +++ b/internal/airplay/capture.go @@ -734,26 +734,15 @@ func pipeWireVideoSourceStage(fd int, nodeID uint32, fps int, copyPortalBuffers fmt.Sprintf("keepalive-time=%d", frameIntervalMillis(fps)), } if copyPortalBuffers { - // The compositor and pipewiresrc's keepalive path both retain the latest - // GstBuffer. With a small portal pool that can keep every PipeWire buffer - // checked out and freeze screencopy. Copying here returns the portal buffer - // as soon as pipewiresrc pulls it while downstream retains only the copy. + // The software path cannot import a portal DMA-BUF through VA-API. Copy + // immediately so downstream never retains a PipeWire-owned buffer. stage = append(stage, "always-copy=true") } return stage } func vaapiVideoImportStages() []gstStage { - // Force a fresh VA surface even when the portal's DMA-BUF already satisfies - // downstream caps, then download it into system memory. The forced-live - // compositor may retain its latest input; retaining the portal DMA-BUF would - // eventually exhaust PipeWire's pool and repeat one stale capture timestamp. - return []gstStage{ - {"vapostproc", "disable-passthrough=true"}, - // An unfeatured raw caps filter means system memory while leaving the - // pixel format negotiable, preserving 10-bit portal input for HEVC. - {"video/x-raw"}, - } + return []gstStage{{"vapostproc"}} } func waylandVideoInputStages(fd int, nodeID uint32, fps int, useVAAPI bool) (gstStage, []gstStage) { diff --git a/internal/airplay/capture_test.go b/internal/airplay/capture_test.go index a6111c4..71a3fd4 100644 --- a/internal/airplay/capture_test.go +++ b/internal/airplay/capture_test.go @@ -390,12 +390,9 @@ func TestPipeWireVideoSourceCopiesPortalBuffersForSoftwareConversion(t *testing. } } -func TestVAAPIPostprocCopiesPortalDMABufferToSystemMemory(t *testing.T) { +func TestVAAPIPostprocReceivesOriginalPortalDMABuffer(t *testing.T) { got := vaapiVideoImportStages() - want := []gstStage{ - {"vapostproc", "disable-passthrough=true"}, - {"video/x-raw"}, - } + want := []gstStage{{"vapostproc"}} if !reflect.DeepEqual(got, want) { t.Fatalf("VA-API import stages = %v, want %v", got, want) } @@ -409,13 +406,10 @@ func TestWaylandVideoInputStagesPreservePortalBufferOwnership(t *testing.T) { wantImports []gstStage }{ { - name: "VA-API imports before system-memory copy", - useVAAPI: true, - wantSource: gstStage{"pipewiresrc", "fd=3", "path=42", "do-timestamp=true", "keepalive-time=33"}, - wantImports: []gstStage{ - {"vapostproc", "disable-passthrough=true"}, - {"video/x-raw"}, - }, + name: "VA-API imports original portal buffer", + useVAAPI: true, + wantSource: gstStage{"pipewiresrc", "fd=3", "path=42", "do-timestamp=true", "keepalive-time=33"}, + wantImports: []gstStage{{"vapostproc"}}, }, { name: "software conversion copies portal buffer", From faca56a06c4fb05405a9ba99a674df4f16d69f36 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 22:36:28 +0900 Subject: [PATCH 07/15] Fix Wayland AirPlay video startup Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/capture.go | 179 ++++++++++++++++----- internal/airplay/capture_broadcast.go | 66 +++++++- internal/airplay/capture_broadcast_test.go | 74 +++++++++ internal/airplay/capture_test.go | 76 ++++++++- 4 files changed, 350 insertions(+), 45 deletions(-) diff --git a/internal/airplay/capture.go b/internal/airplay/capture.go index 75d5b86..495ddeb 100644 --- a/internal/airplay/capture.go +++ b/internal/airplay/capture.go @@ -61,19 +61,25 @@ const ( // uses a fixed resolution. testCaptureWidth = 1920 testCaptureHeight = 1080 + + // The isolated Wayland encoder receives copied raw frames over a pipe. Its + // bounded copy/encode interval exceeds Apple's nominal 75 ms screen lead on + // the supported integrated-GPU path. + waylandRawRelayMinimumVideoLead = 250 * time.Millisecond ) // ScreenCapture manages screen capture via GStreamer. type ScreenCapture struct { - cmd *exec.Cmd // gst-launch-1.0 process - stdout io.ReadCloser - frames videoAccessUnitReader - cancel context.CancelFunc - pwNodeID uint32 - dbusConn *dbus.Conn // portal session D-Bus connection (must stay open for Wayland) - waitCh chan struct{} // closed when process exits - waitErr error // set before waitCh is closed - stopped bool + cmd *exec.Cmd // gst-launch-1.0 encoder process + sourceCmd *exec.Cmd // optional Wayland capture/serialization process + stdout io.ReadCloser + frames videoAccessUnitReader + cancel context.CancelFunc + pwNodeID uint32 + dbusConn *dbus.Conn // portal session D-Bus connection (must stay open for Wayland) + waitCh chan struct{} // closed when process exits + waitErr error // set before waitCh is closed + stopped bool } type capturePreparationKind uint8 @@ -84,6 +90,13 @@ const ( capturePreparationTest ) +func captureMinimumVideoLead(kind capturePreparationKind, measured time.Duration) time.Duration { + if kind == capturePreparationWayland && measured < waylandRawRelayMinimumVideoLead { + return waylandRawRelayMinimumVideoLead + } + return measured +} + // CapturePreparation performs the potentially interactive part of screen // capture before the receiver session starts. In particular, a Wayland // preparation completes the screencast portal request and retains its PipeWire @@ -101,8 +114,8 @@ type CapturePreparation struct { timestampedOutput bool automaticHEVCAvail bool - // measuredVideoLatency is the minimum screen lead measured by the local 4K - // HEVC preflight. It is zero for H.264 and unmeasured software fallback. + // measuredVideoLatency is the minimum screen lead required by local capture: + // either the 4K HEVC preflight or the isolated Wayland raw relay. measuredVideoLatency time.Duration pwNodeID uint32 @@ -204,6 +217,7 @@ func PrepareCapture(ctx context.Context, cfg CaptureConfig) (*CapturePreparation preparation.pwNodeID = nodeID preparation.pwFd = pwFd preparation.dbusConn = dbusConn + preparation.measuredVideoLatency = captureMinimumVideoLead(kind, preparation.measuredVideoLatency) return preparation, nil } @@ -742,7 +756,10 @@ func pipeWireVideoSourceStage(fd int, nodeID uint32, fps int, copyPortalBuffers } func vaapiVideoImportStages() []gstStage { - return []gstStage{{"vapostproc"}} + return []gstStage{ + {"vapostproc", "disable-passthrough=true"}, + {"video/x-raw,format=NV12"}, + } } func waylandVideoInputStages(fd int, nodeID uint32, fps int, useVAAPI bool) (gstStage, []gstStage) { @@ -814,6 +831,10 @@ func buildGstVideoPipeline(source gstStage, beforeConvert, afterScale []gstStage for _, stage := range afterScale { args = appendGstStage(args, stage) } + return appendGstEncoderPipeline(args, encoder, timestampedOutput) +} + +func appendGstEncoderPipeline(args []string, encoder encoderResult, timestampedOutput bool) []string { if encoder.needsVulkan { args = appendGstStage(args, gstStage{"vulkanupload"}) } @@ -840,6 +861,35 @@ func buildGstVideoPipeline(source gstStage, beforeConvert, afterScale []gstStage return appendGstStage(args, gstStage{"fdsink", "fd=1", "sync=false", "async=false"}) } +func buildSplitGstVideoPipeline(source gstStage, beforeConvert, afterScale []gstStage, encoder encoderResult, maxWidth, maxHeight, fps int, timestampedOutput bool) (producer, consumer []string) { + producer = append([]string{"--quiet"}, source...) + for _, stage := range beforeConvert { + producer = appendGstStage(producer, stage) + } + producer = appendGstStage(producer, gstStage{"videoconvert"}) + producer = appendGstStage(producer, gstStage{fmt.Sprintf("video/x-raw,format=%s", encoder.rawFormat)}) + for _, stage := range receiverScaleStages(maxWidth, maxHeight) { + producer = appendGstStage(producer, stage) + } + for _, stage := range afterScale { + producer = appendGstStage(producer, stage) + } + producer = appendGstStage(producer, gstStage{"fdsink", "fd=1", "sync=false", "async=false"}) + + maxWidth &^= 1 + maxHeight &^= 1 + consumer = []string{"--quiet", "fdsrc", "fd=0", "do-timestamp=true"} + consumer = appendGstStage(consumer, gstStage{ + fmt.Sprintf( + "video/x-raw,format=%s,width=%d,height=%d,framerate=%d/1", + encoder.rawFormat, maxWidth, maxHeight, fps, + ), + }) + consumer = appendGstStage(consumer, gstStage{"rawvideoparse", "use-sink-caps=true"}) + consumer = appendGstEncoderPipeline(consumer, encoder, timestampedOutput) + return producer, consumer +} + func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoderParts encoderResult, nodeID uint32, pwFd *os.File, dbusConn *dbus.Conn, streamSize [2]int, timestampedOutput bool) (*ScreenCapture, error) { if pwFd == nil || dbusConn == nil { if pwFd != nil { @@ -882,58 +932,107 @@ func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoder beforeConvert = append(beforeConvert, gstStage{"compositor", "force-live=true", "ignore-inactive-pads=true", "background=black"}, gstStage{fmt.Sprintf("video/x-raw,width=%d,height=%d,framerate=%d/1", streamSize[0], streamSize[1], fps)}, + lowLatencyVideoQueueStage(), ) } else { log.Printf("[CAPTURE] idle-frame compositor unavailable; using portal frame timing") } - if hasCompositor { - afterScale = append(afterScale, lowLatencyVideoQueueStage()) - } else { + if !hasCompositor { afterScale = append(afterScale, gstStage{"videorate", "drop-only=true", "skip-to-first=true"}, frameRateStage(fps), lowLatencyVideoQueueStage(), ) } - gstArgs := buildGstVideoPipeline(source, beforeConvert, afterScale, encoderParts, cfg.MaxWidth, cfg.MaxHeight, timestampedOutput) - - dbg("[CAPTURE] gst-launch-1.0 (wayland) %s", strings.Join(gstArgs, " ")) - cmd := exec.CommandContext(captureCtx, "gst-launch-1.0", gstArgs...) - cmd.ExtraFiles = []*os.File{pwFd} + rawWidth, rawHeight := cfg.MaxWidth&^1, cfg.MaxHeight&^1 + if rawWidth <= 0 || rawHeight <= 0 { + rawWidth, rawHeight = streamSize[0]&^1, streamSize[1]&^1 + } + if rawWidth <= 0 || rawHeight <= 0 { + cancel() + _ = pwFd.Close() + _ = dbusConn.Close() + return nil, fmt.Errorf("Wayland capture is missing both receiver and portal dimensions") + } + sourceArgs, encoderArgs := buildSplitGstVideoPipeline( + source, beforeConvert, afterScale, encoderParts, + rawWidth, rawHeight, fps, timestampedOutput, + ) + dbg("[CAPTURE] gst-launch-1.0 (wayland source) %s", strings.Join(sourceArgs, " ")) + dbg("[CAPTURE] gst-launch-1.0 (wayland encoder) %s", strings.Join(encoderArgs, " ")) + + sourceCmd := exec.CommandContext(captureCtx, "gst-launch-1.0", sourceArgs...) + sourceCmd.ExtraFiles = []*os.File{pwFd} + rawFrames, err := sourceCmd.StdoutPipe() + if err != nil { + cancel() + _ = pwFd.Close() + _ = dbusConn.Close() + return nil, fmt.Errorf("capture serialization pipe: %w", err) + } + sourceStderr, _ := sourceCmd.StderrPipe() + cmd := exec.CommandContext(captureCtx, "gst-launch-1.0", encoderArgs...) + cmd.Stdin = rawFrames stdout, err := cmd.StdoutPipe() if err != nil { cancel() - pwFd.Close() - dbusConn.Close() - return nil, fmt.Errorf("gst stdout pipe: %w", err) + _ = rawFrames.Close() + _ = pwFd.Close() + _ = dbusConn.Close() + return nil, fmt.Errorf("encoder stdout pipe: %w", err) } - stderr, _ := cmd.StderrPipe() + encoderStderr, _ := cmd.StderrPipe() - waitResult, err := startGStreamerCommand(cmd) + encoderWait, err := startGStreamerCommand(cmd) if err != nil { cancel() - pwFd.Close() - dbusConn.Close() - return nil, fmt.Errorf("start gst-launch: %w", err) + _ = rawFrames.Close() + _ = pwFd.Close() + _ = dbusConn.Close() + return nil, fmt.Errorf("start encoder gst-launch: %w", err) } - pwFd.Close() // child inherited it + sourceWait, err := startGStreamerCommand(sourceCmd) + if err != nil { + cancel() + _ = rawFrames.Close() + _ = pwFd.Close() + _ = dbusConn.Close() + <-encoderWait + return nil, fmt.Errorf("start capture gst-launch: %w", err) + } + _ = pwFd.Close() // source child inherited it - go logStderr("GST", stderr) + go logStderr("GST-SOURCE", sourceStderr) + go logStderr("GST-ENCODER", encoderStderr) capture := &ScreenCapture{ - cmd: cmd, - stdout: stdout, - cancel: cancel, - pwNodeID: nodeID, - dbusConn: dbusConn, - waitCh: make(chan struct{}), + cmd: cmd, + sourceCmd: sourceCmd, + stdout: stdout, + cancel: cancel, + pwNodeID: nodeID, + dbusConn: dbusConn, + waitCh: make(chan struct{}), } if timestampedOutput { capture.frames = newRTPVideoAccessUnitReader(stdout, encoderParts.codec) } go func() { - capture.waitErr = <-waitResult + type processResult struct { + name string + err error + } + results := make(chan processResult, 2) + go func() { results <- processResult{name: "capture", err: <-sourceWait} }() + go func() { results <- processResult{name: "encoder", err: <-encoderWait} }() + first := <-results + dbg("[CAPTURE] %s pipeline exited: %v", first.name, first.err) + cancel() + <-results + if first.err != nil { + capture.waitErr = fmt.Errorf("%s pipeline: %w", first.name, first.err) + } close(capture.waitCh) }() @@ -1069,6 +1168,9 @@ func (sc *ScreenCapture) Stop() { if sc.cmd != nil && sc.cmd.Process != nil { _ = sc.cmd.Process.Signal(os.Interrupt) } + if sc.sourceCmd != nil && sc.sourceCmd.Process != nil { + _ = sc.sourceCmd.Process.Signal(os.Interrupt) + } select { case <-sc.waitCh: @@ -1076,6 +1178,9 @@ func (sc *ScreenCapture) Stop() { if sc.cmd != nil && sc.cmd.Process != nil { _ = sc.cmd.Process.Kill() } + if sc.sourceCmd != nil && sc.sourceCmd.Process != nil { + _ = sc.sourceCmd.Process.Kill() + } <-sc.waitCh } } diff --git a/internal/airplay/capture_broadcast.go b/internal/airplay/capture_broadcast.go index 2177cab..e2a70df 100644 --- a/internal/airplay/capture_broadcast.go +++ b/internal/airplay/capture_broadcast.go @@ -24,8 +24,10 @@ const ( broadcastSinkDrainTimeout = 2 * time.Second ) -var errBroadcastSinkBacklog = errors.New("broadcast sink backlog limit exceeded") -var errBroadcastSinkMode = errors.New("backpressured broadcast sink requires an otherwise unused capture") +var ( + errBroadcastSinkBacklog = errors.New("broadcast sink backlog limit exceeded") + errBroadcastSinkMode = errors.New("backpressured broadcast sink requires an otherwise unused capture") +) // BroadcastCapture reads from a single ScreenCapture and fans the raw byte // stream out to multiple registered sinks. Each sink has an independent, @@ -55,6 +57,10 @@ type BroadcastCapture struct { // following sequence, which gives attachment an exact cutover even when a // source read has completed but has not yet been fanned out. sequence uint64 + // primer is the latest complete parameter-set plus random-access AU. A + // receiver attached after capture starts needs it before live P-frames are + // decodable. + primer VideoAccessUnit drainTimeout time.Duration @@ -72,6 +78,7 @@ type BroadcastSink struct { queue [][]byte frameQueue []VideoAccessUnit + primer VideoAccessUnit headOffset int queuedBytes int // queuedFrameDuration is a nominal sample-duration sum. It deliberately does @@ -154,6 +161,7 @@ func (bc *BroadcastCapture) AddSink() *BroadcastSink { return s } s.startSequence = bc.sequence + 1 + s.primer = bc.primer bc.sinks = append(bc.sinks, s) bc.mu.Unlock() return s @@ -179,6 +187,7 @@ func (bc *BroadcastCapture) AddBackpressuredSink() (*BroadcastSink, error) { } bc.exclusive = true s.startSequence = bc.sequence + 1 + s.primer = bc.primer bc.sinks = append(bc.sinks, s) bc.mu.Unlock() return s, nil @@ -266,6 +275,12 @@ func (bc *BroadcastCapture) runFrames() error { frame, readErr := bc.src.ReadVideoAccessUnit() if len(frame.AnnexB) > 0 { bc.mu.Lock() + if len(frame.AnnexB) <= broadcastSinkQueueBytes && isDecoderPrimer(frame.AnnexB) { + bc.primer = VideoAccessUnit{ + AnnexB: append(bc.primer.AnnexB[:0], frame.AnnexB...), + PTS: frame.PTS, + } + } sinks := make([]*BroadcastSink, 0, len(bc.sinks)) for _, sink := range bc.sinks { if sink.startSequence <= sequence { @@ -287,6 +302,39 @@ func (bc *BroadcastCapture) runFrames() error { } } +func isDecoderPrimer(annexB []byte) bool { + var h264SPS, h264PPS, h264IDR bool + var hevcVPS, hevcSPS, hevcPPS, hevcIRAP bool + for _, nal := range splitAnnexBAccessUnit(annexB) { + raw := stripStartCode(nal) + if len(raw) == 0 { + continue + } + switch raw[0] & 0x1f { + case 5: + h264IDR = true + case 7: + h264SPS = true + case 8: + h264PPS = true + } + if len(raw) < 2 { + continue + } + switch nalType := hevcNALType(raw); nalType { + case 32: + hevcVPS = true + case 33: + hevcSPS = true + case 34: + hevcPPS = true + default: + hevcIRAP = hevcIRAP || nalType >= 16 && nalType <= 23 + } + } + return h264SPS && h264PPS && h264IDR || hevcVPS && hevcSPS && hevcPPS && hevcIRAP +} + // finish stops accepting sinks, lets existing sinks drain, and only then // publishes BroadcastCapture completion. func (bc *BroadcastCapture) finish(err error) { @@ -422,7 +470,7 @@ func (s *BroadcastSink) enqueueFrame(frame VideoAccessUnit) error { } func (s *BroadcastSink) queueEmptyLocked() bool { - return len(s.queue) == 0 && len(s.frameQueue) == 0 + return len(s.queue) == 0 && len(s.frameQueue) == 0 && len(s.primer.AnnexB) == 0 } // finish marks source EOF without discarding data already queued. @@ -452,6 +500,7 @@ func (s *BroadcastSink) abort() { s.frameQueue[i].AnnexB = nil } s.frameQueue = nil + s.primer = VideoAccessUnit{} s.headOffset = 0 s.queuedBytes = 0 s.queuedFrameDuration = 0 @@ -519,6 +568,17 @@ func (s *BroadcastSink) ReadVideoAccessUnit() (VideoAccessUnit, error) { if s.closed { return VideoAccessUnit{}, io.EOF } + if len(s.primer.AnnexB) > 0 { + primer := s.primer + s.primer = VideoAccessUnit{} + if len(s.frameQueue) > 0 && !s.frameQueue[0].PTS.IsZero() { + primer.PTS = s.frameQueue[0].PTS.Add(-s.frameDuration) + } + if s.inputClosed && len(s.frameQueue) == 0 && len(s.queue) == 0 { + s.closeDoneLocked() + } + return primer, nil + } if len(s.frameQueue) == 0 { s.closeDoneLocked() return VideoAccessUnit{}, io.EOF diff --git a/internal/airplay/capture_broadcast_test.go b/internal/airplay/capture_broadcast_test.go index 616b3f5..e12ce7c 100644 --- a/internal/airplay/capture_broadcast_test.go +++ b/internal/airplay/capture_broadcast_test.go @@ -18,6 +18,11 @@ type channelVideoAccessUnitReader struct { frames <-chan VideoAccessUnit } +type signaledVideoAccessUnitReader struct { + frames <-chan VideoAccessUnit + reads chan<- struct{} +} + func waitForBroadcastSinkState(t *testing.T, sink *BroadcastSink, predicate func(*BroadcastSink) bool, description string) { t.Helper() deadline := time.Now().Add(time.Second) @@ -50,6 +55,15 @@ func (r *channelVideoAccessUnitReader) ReadVideoAccessUnit() (VideoAccessUnit, e return frame, nil } +func (r *signaledVideoAccessUnitReader) ReadVideoAccessUnit() (VideoAccessUnit, error) { + r.reads <- struct{}{} + frame, ok := <-r.frames + if !ok { + return VideoAccessUnit{}, io.EOF + } + return frame, nil +} + func (r *sliceVideoAccessUnitReader) ReadVideoAccessUnit() (VideoAccessUnit, error) { if r.index == len(r.frames) { return VideoAccessUnit{}, io.EOF @@ -101,6 +115,66 @@ func TestBroadcastCapturePreservesTimestampedAccessUnits(t *testing.T) { } } +func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { + frames := make(chan VideoAccessUnit) + reads := make(chan struct{}, 4) + capture := &ScreenCapture{ + frames: &signaledVideoAccessUnitReader{frames: frames, reads: reads}, + waitCh: make(chan struct{}), + } + broadcast := NewBroadcastCaptureWithFrameRate(capture, 30) + runDone := make(chan error, 1) + go func() { runDone <- broadcast.Run() }() + + <-reads + primer := VideoAccessUnit{ + AnnexB: []byte{ + 0, 0, 0, 1, 0x67, 0x42, 0x00, 0x1f, + 0, 0, 0, 1, 0x68, 0xce, 0x06, 0xe2, + 0, 0, 0, 1, 0x65, 0x80, + }, + PTS: time.Unix(100, 0), + } + frames <- primer + <-reads + + sink := broadcast.AddSink() + defer sink.Close() + boundary := VideoAccessUnit{ + AnnexB: []byte{0, 0, 0, 1, 0x61, 0x40}, + PTS: primer.PTS.Add(10 * time.Second), + } + frames <- boundary + <-reads + live := VideoAccessUnit{ + AnnexB: []byte{0, 0, 0, 1, 0x61, 0x80}, + PTS: boundary.PTS.Add(time.Second / 30), + } + frames <- live + close(frames) + + replayed, err := sink.ReadVideoAccessUnit() + if err != nil { + t.Fatalf("read replayed decoder primer: %v", err) + } + if !bytes.Equal(replayed.AnnexB, primer.AnnexB) { + t.Fatalf("first late-sink frame = %x, want cached decoder primer %x", replayed.AnnexB, primer.AnnexB) + } + if wantPTS := live.PTS.Add(-time.Second / 30); !replayed.PTS.Equal(wantPTS) { + t.Fatalf("replayed primer PTS = %v, want one frame before live PTS %v", replayed.PTS, wantPTS) + } + next, err := sink.ReadVideoAccessUnit() + if err != nil { + t.Fatalf("read live frame after decoder primer: %v", err) + } + if !bytes.Equal(next.AnnexB, live.AnnexB) || !next.PTS.Equal(live.PTS) { + t.Fatalf("live frame after primer = {%x %v}, want {%x %v}", next.AnnexB, next.PTS, live.AnnexB, live.PTS) + } + if err := <-runDone; !errors.Is(err, io.EOF) { + t.Fatalf("broadcast run = %v, want EOF", err) + } +} + func TestBroadcastSinkBackpressuresWithOnePendingAccessUnit(t *testing.T) { sink := newBroadcastSinkWithPolicy(nil, true) base := time.Now() diff --git a/internal/airplay/capture_test.go b/internal/airplay/capture_test.go index 71a3fd4..512182f 100644 --- a/internal/airplay/capture_test.go +++ b/internal/airplay/capture_test.go @@ -138,6 +138,19 @@ func TestRecommendedAutomaticVideoLatencyUsesP95AndDeliveryMargin(t *testing.T) } } +func TestCaptureMinimumVideoLeadIncludesWaylandRawRelay(t *testing.T) { + const measured = 300 * time.Millisecond + if got := captureMinimumVideoLead(capturePreparationWayland, 0); got != 250*time.Millisecond { + t.Fatalf("unmeasured Wayland split lead = %v, want 250ms", got) + } + if got := captureMinimumVideoLead(capturePreparationWayland, measured); got != measured { + t.Fatalf("larger measured Wayland lead = %v, want %v", got, measured) + } + if got := captureMinimumVideoLead(capturePreparationX11, 0); got != 0 { + t.Fatalf("X11 minimum lead = %v, want no split-pipeline override", got) + } +} + func TestLiveVideoProbeTimeoutTracksConfiguredFrameRate(t *testing.T) { if got := liveVideoProbeTimeout(30); got != minimumLiveVideoProbeTimeout { t.Fatalf("30fps live probe timeout = %v, want %v", got, minimumLiveVideoProbeTimeout) @@ -392,7 +405,10 @@ func TestPipeWireVideoSourceCopiesPortalBuffersForSoftwareConversion(t *testing. func TestVAAPIPostprocReceivesOriginalPortalDMABuffer(t *testing.T) { got := vaapiVideoImportStages() - want := []gstStage{{"vapostproc"}} + want := []gstStage{ + {"vapostproc", "disable-passthrough=true"}, + {"video/x-raw,format=NV12"}, + } if !reflect.DeepEqual(got, want) { t.Fatalf("VA-API import stages = %v, want %v", got, want) } @@ -406,10 +422,13 @@ func TestWaylandVideoInputStagesPreservePortalBufferOwnership(t *testing.T) { wantImports []gstStage }{ { - name: "VA-API imports original portal buffer", - useVAAPI: true, - wantSource: gstStage{"pipewiresrc", "fd=3", "path=42", "do-timestamp=true", "keepalive-time=33"}, - wantImports: []gstStage{{"vapostproc"}}, + name: "VA-API imports original portal buffer", + useVAAPI: true, + wantSource: gstStage{"pipewiresrc", "fd=3", "path=42", "do-timestamp=true", "keepalive-time=33"}, + wantImports: []gstStage{ + {"vapostproc", "disable-passthrough=true"}, + {"video/x-raw,format=NV12"}, + }, }, { name: "software conversion copies portal buffer", @@ -504,6 +523,53 @@ func TestBuildGstVideoPipeline(t *testing.T) { } } +func TestBuildSplitWaylandVideoPipelineSerializesRawFramesBeforeEncoding(t *testing.T) { + encoder := encoderResult{ + parts: gstStage{"testh264enc", "bitrate=2500"}, + rawFormat: "NV12", + } + producer, consumer := buildSplitGstVideoPipeline( + gstStage{"pipewiresrc", "fd=3", "path=42"}, + []gstStage{ + {"vapostproc"}, + {"compositor", "force-live=true"}, + {"video/x-raw,width=2880,height=1800,framerate=30/1"}, + lowLatencyVideoQueueStage(), + }, + nil, + encoder, + 1280, + 720, + 30, + true, + ) + wantProducerSuffix := []string{ + "!", "video/x-raw,width=1280,height=720,pixel-aspect-ratio=1/1", + "!", "fdsink", "fd=1", "sync=false", "async=false", + } + if got := producer[len(producer)-len(wantProducerSuffix):]; !reflect.DeepEqual(got, wantProducerSuffix) { + t.Fatalf("raw producer suffix = %v, want %v", got, wantProducerSuffix) + } + wantConsumerPrefix := []string{ + "--quiet", "fdsrc", "fd=0", "do-timestamp=true", + "!", "video/x-raw,format=NV12,width=1280,height=720,framerate=30/1", + "!", "rawvideoparse", "use-sink-caps=true", + "!", "testh264enc", "bitrate=2500", + } + if got := consumer[:len(wantConsumerPrefix)]; !reflect.DeepEqual(got, wantConsumerPrefix) { + t.Fatalf("encoder consumer prefix = %v, want %v", got, wantConsumerPrefix) + } + wantConsumerSuffix := []string{ + "!", "rtph264pay", "pt=96", "mtu=60000", "aggregate-mode=none", "timestamp-offset=0", "seqnum-offset=0", + "!", "rtponviftimestamp", "ntp-offset=-1", "set-e-bit=false", "set-t-bit=false", + "!", "rtpstreampay", + "!", "fdsink", "fd=1", "sync=false", "async=false", + } + if got := consumer[len(consumer)-len(wantConsumerSuffix):]; !reflect.DeepEqual(got, wantConsumerSuffix) { + t.Fatalf("timestamped consumer suffix = %v, want %v", got, wantConsumerSuffix) + } +} + func TestBuildGstVideoPipelineTimestampedOutput(t *testing.T) { encoder := encoderResult{parts: gstStage{"testh264enc"}, rawFormat: "I420"} pipeline := buildGstVideoPipeline( From 48fcdbacd5f05b3a432910af43ff60f04ded5f4b Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 22:49:11 +0900 Subject: [PATCH 08/15] Harden Wayland video relay Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- cmd/doubletake/main.go | 1 + internal/airplay/capture.go | 26 +++++-- internal/airplay/capture_broadcast.go | 21 +++--- internal/airplay/capture_broadcast_test.go | 83 +++++++++++++++++++--- internal/airplay/capture_test.go | 11 +++ internal/airplay/client.go | 1 + internal/airplay/mirror.go | 3 + internal/airplay/receiver_server_test.go | 31 ++++++++ internal/daemon/daemon.go | 1 + 9 files changed, 154 insertions(+), 24 deletions(-) diff --git a/cmd/doubletake/main.go b/cmd/doubletake/main.go index b49aab6..772398e 100644 --- a/cmd/doubletake/main.go +++ b/cmd/doubletake/main.go @@ -371,6 +371,7 @@ func main() { } streamCfg.AutomaticHEVCAvailable = capturePreparation.AutomaticHEVCAvailable() streamCfg.MeasuredVideoLatency = capturePreparation.MeasuredVideoLatency() + streamCfg.MinimumVideoLead = capturePreparation.MinimumVideoLead() var capture *airplay.ScreenCapture var broadcast *airplay.BroadcastCapture diff --git a/internal/airplay/capture.go b/internal/airplay/capture.go index 495ddeb..46e172e 100644 --- a/internal/airplay/capture.go +++ b/internal/airplay/capture.go @@ -97,6 +97,14 @@ func captureMinimumVideoLead(kind capturePreparationKind, measured time.Duration return measured } +func waylandRawVideoSize(receiverWidth, receiverHeight int, streamSize [2]int) (int, int) { + if receiverWidth <= 0 || receiverHeight <= 0 { + receiverWidth, receiverHeight = streamSize[0], streamSize[1] + } + receiverWidth, receiverHeight = fitVideoSize(receiverWidth, receiverHeight, 3840, 2160) + return receiverWidth &^ 1, receiverHeight &^ 1 +} + // CapturePreparation performs the potentially interactive part of screen // capture before the receiver session starts. In particular, a Wayland // preparation completes the screencast portal request and retains its PipeWire @@ -115,8 +123,9 @@ type CapturePreparation struct { timestampedOutput bool automaticHEVCAvail bool // measuredVideoLatency is the minimum screen lead required by local capture: - // either the 4K HEVC preflight or the isolated Wayland raw relay. + // the 4K HEVC preflight. minimumVideoLead also includes transport overhead. measuredVideoLatency time.Duration + minimumVideoLead time.Duration pwNodeID uint32 pwFd *os.File @@ -217,7 +226,7 @@ func PrepareCapture(ctx context.Context, cfg CaptureConfig) (*CapturePreparation preparation.pwNodeID = nodeID preparation.pwFd = pwFd preparation.dbusConn = dbusConn - preparation.measuredVideoLatency = captureMinimumVideoLead(kind, preparation.measuredVideoLatency) + preparation.minimumVideoLead = captureMinimumVideoLead(kind, preparation.measuredVideoLatency) return preparation, nil } @@ -405,6 +414,14 @@ func (p *CapturePreparation) MeasuredVideoLatency() time.Duration { return p.measuredVideoLatency } +// MinimumVideoLead returns the full local capture/transport scheduling floor. +func (p *CapturePreparation) MinimumVideoLead() time.Duration { + if p == nil { + return 0 + } + return p.minimumVideoLead +} + // Close releases an unconsumed portal preparation. Once Start has taken // ownership, ScreenCapture.Stop owns the corresponding resources. func (p *CapturePreparation) Close() { @@ -944,10 +961,7 @@ func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoder lowLatencyVideoQueueStage(), ) } - rawWidth, rawHeight := cfg.MaxWidth&^1, cfg.MaxHeight&^1 - if rawWidth <= 0 || rawHeight <= 0 { - rawWidth, rawHeight = streamSize[0]&^1, streamSize[1]&^1 - } + rawWidth, rawHeight := waylandRawVideoSize(cfg.MaxWidth, cfg.MaxHeight, streamSize) if rawWidth <= 0 || rawHeight <= 0 { cancel() _ = pwFd.Close() diff --git a/internal/airplay/capture_broadcast.go b/internal/airplay/capture_broadcast.go index e2a70df..1f1ec61 100644 --- a/internal/airplay/capture_broadcast.go +++ b/internal/airplay/capture_broadcast.go @@ -48,6 +48,7 @@ type BroadcastCapture struct { // adjacent PTS values. A leaky upstream queue can legitimately create large // PTS gaps while only one encoded picture is pending. frameDuration time.Duration + now func() time.Time mu sync.Mutex done chan struct{} err error // set before done is closed @@ -57,9 +58,9 @@ type BroadcastCapture struct { // following sequence, which gives attachment an exact cutover even when a // source read has completed but has not yet been fanned out. sequence uint64 - // primer is the latest complete parameter-set plus random-access AU. A - // receiver attached after capture starts needs it before live P-frames are - // decodable. + // Timestamped access-unit fan-out caches the latest complete parameter-set + // plus random-access AU. Legacy byte-stream fan-out retains its exact + // next-read cutover and therefore waits for the encoder's next keyframe. primer VideoAccessUnit drainTimeout time.Duration @@ -143,6 +144,7 @@ func NewBroadcastCaptureWithFrameRate(src *ScreenCapture, fps int) *BroadcastCap src: src, frames: src != nil && src.frames != nil, frameDuration: time.Second / time.Duration(fps), + now: time.Now, done: make(chan struct{}), drainTimeout: broadcastSinkDrainTimeout, } @@ -162,6 +164,9 @@ func (bc *BroadcastCapture) AddSink() *BroadcastSink { } s.startSequence = bc.sequence + 1 s.primer = bc.primer + if len(s.primer.AnnexB) > 0 { + s.primer.PTS = bc.now() + } bc.sinks = append(bc.sinks, s) bc.mu.Unlock() return s @@ -188,6 +193,9 @@ func (bc *BroadcastCapture) AddBackpressuredSink() (*BroadcastSink, error) { bc.exclusive = true s.startSequence = bc.sequence + 1 s.primer = bc.primer + if len(s.primer.AnnexB) > 0 { + s.primer.PTS = bc.now() + } bc.sinks = append(bc.sinks, s) bc.mu.Unlock() return s, nil @@ -277,7 +285,7 @@ func (bc *BroadcastCapture) runFrames() error { bc.mu.Lock() if len(frame.AnnexB) <= broadcastSinkQueueBytes && isDecoderPrimer(frame.AnnexB) { bc.primer = VideoAccessUnit{ - AnnexB: append(bc.primer.AnnexB[:0], frame.AnnexB...), + AnnexB: append([]byte(nil), frame.AnnexB...), PTS: frame.PTS, } } @@ -562,7 +570,7 @@ func (s *BroadcastSink) Read(p []byte) (int, error) { func (s *BroadcastSink) ReadVideoAccessUnit() (VideoAccessUnit, error) { s.mu.Lock() defer s.mu.Unlock() - for len(s.frameQueue) == 0 && !s.inputClosed && !s.closed { + for len(s.primer.AnnexB) == 0 && len(s.frameQueue) == 0 && !s.inputClosed && !s.closed { s.cond.Wait() } if s.closed { @@ -571,9 +579,6 @@ func (s *BroadcastSink) ReadVideoAccessUnit() (VideoAccessUnit, error) { if len(s.primer.AnnexB) > 0 { primer := s.primer s.primer = VideoAccessUnit{} - if len(s.frameQueue) > 0 && !s.frameQueue[0].PTS.IsZero() { - primer.PTS = s.frameQueue[0].PTS.Add(-s.frameDuration) - } if s.inputClosed && len(s.frameQueue) == 0 && len(s.queue) == 0 { s.closeDoneLocked() } diff --git a/internal/airplay/capture_broadcast_test.go b/internal/airplay/capture_broadcast_test.go index e12ce7c..e486843 100644 --- a/internal/airplay/capture_broadcast_test.go +++ b/internal/airplay/capture_broadcast_test.go @@ -123,6 +123,8 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { waitCh: make(chan struct{}), } broadcast := NewBroadcastCaptureWithFrameRate(capture, 30) + replayPTS := time.Unix(110, 0) + broadcast.now = func() time.Time { return replayPTS } runDone := make(chan error, 1) go func() { runDone <- broadcast.Run() }() @@ -140,6 +142,34 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { sink := broadcast.AddSink() defer sink.Close() + replayDone := make(chan struct { + frame VideoAccessUnit + err error + }, 1) + go func() { + frame, err := sink.ReadVideoAccessUnit() + replayDone <- struct { + frame VideoAccessUnit + err error + }{frame: frame, err: err} + }() + var replayed VideoAccessUnit + select { + case result := <-replayDone: + if result.err != nil { + t.Fatalf("read replayed decoder primer: %v", result.err) + } + replayed = result.frame + case <-time.After(time.Second): + t.Fatal("cached decoder primer was not available immediately") + } + if !bytes.Equal(replayed.AnnexB, primer.AnnexB) { + t.Fatalf("first late-sink frame = %x, want cached decoder primer %x", replayed.AnnexB, primer.AnnexB) + } + if !replayed.PTS.Equal(replayPTS) { + t.Fatalf("replayed primer PTS = %v, want attachment PTS %v", replayed.PTS, replayPTS) + } + boundary := VideoAccessUnit{ AnnexB: []byte{0, 0, 0, 1, 0x61, 0x40}, PTS: primer.PTS.Add(10 * time.Second), @@ -153,16 +183,6 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { frames <- live close(frames) - replayed, err := sink.ReadVideoAccessUnit() - if err != nil { - t.Fatalf("read replayed decoder primer: %v", err) - } - if !bytes.Equal(replayed.AnnexB, primer.AnnexB) { - t.Fatalf("first late-sink frame = %x, want cached decoder primer %x", replayed.AnnexB, primer.AnnexB) - } - if wantPTS := live.PTS.Add(-time.Second / 30); !replayed.PTS.Equal(wantPTS) { - t.Fatalf("replayed primer PTS = %v, want one frame before live PTS %v", replayed.PTS, wantPTS) - } next, err := sink.ReadVideoAccessUnit() if err != nil { t.Fatalf("read live frame after decoder primer: %v", err) @@ -175,6 +195,49 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { } } +func TestBroadcastCapturePrimerSnapshotSurvivesRefresh(t *testing.T) { + frames := make(chan VideoAccessUnit) + reads := make(chan struct{}, 4) + capture := &ScreenCapture{ + frames: &signaledVideoAccessUnitReader{frames: frames, reads: reads}, + waitCh: make(chan struct{}), + } + broadcast := NewBroadcastCapture(capture) + broadcast.now = func() time.Time { return time.Unix(120, 0) } + runDone := make(chan error, 1) + go func() { runDone <- broadcast.Run() }() + + first := VideoAccessUnit{AnnexB: []byte{ + 0, 0, 0, 1, 0x67, 0x42, + 0, 0, 0, 1, 0x68, 0xce, + 0, 0, 0, 1, 0x65, 0xaa, + }} + second := VideoAccessUnit{AnnexB: []byte{ + 0, 0, 0, 1, 0x67, 0x64, + 0, 0, 0, 1, 0x68, 0xee, + 0, 0, 0, 1, 0x65, 0xbb, + }} + <-reads + frames <- first + <-reads + sink := broadcast.AddSink() + defer sink.Close() + frames <- second + <-reads + + got, err := sink.ReadVideoAccessUnit() + if err != nil { + t.Fatalf("read primer snapshot: %v", err) + } + if !bytes.Equal(got.AnnexB, first.AnnexB) { + t.Fatalf("primer snapshot = %x, want %x", got.AnnexB, first.AnnexB) + } + close(frames) + if err := <-runDone; !errors.Is(err, io.EOF) { + t.Fatalf("broadcast run = %v, want EOF", err) + } +} + func TestBroadcastSinkBackpressuresWithOnePendingAccessUnit(t *testing.T) { sink := newBroadcastSinkWithPolicy(nil, true) base := time.Now() diff --git a/internal/airplay/capture_test.go b/internal/airplay/capture_test.go index 512182f..cfca892 100644 --- a/internal/airplay/capture_test.go +++ b/internal/airplay/capture_test.go @@ -151,6 +151,17 @@ func TestCaptureMinimumVideoLeadIncludesWaylandRawRelay(t *testing.T) { } } +func TestWaylandRawVideoSizeBoundsReceiverCanvas(t *testing.T) { + width, height := waylandRawVideoSize(1<<30, 1<<30, [2]int{2880, 1800}) + if width != 2160 || height != 2160 { + t.Fatalf("hostile square receiver canvas = %dx%d, want bounded 2160x2160", width, height) + } + width, height = waylandRawVideoSize(0, 0, [2]int{2881, 1801}) + if width != 2880 || height != 1800 { + t.Fatalf("portal fallback canvas = %dx%d, want even 2880x1800", width, height) + } +} + func TestLiveVideoProbeTimeoutTracksConfiguredFrameRate(t *testing.T) { if got := liveVideoProbeTimeout(30); got != minimumLiveVideoProbeTimeout { t.Fatalf("30fps live probe timeout = %v, want %v", got, minimumLiveVideoProbeTimeout) diff --git a/internal/airplay/client.go b/internal/airplay/client.go index cd35c0e..0a294c7 100644 --- a/internal/airplay/client.go +++ b/internal/airplay/client.go @@ -1203,6 +1203,7 @@ type StreamConfig struct { VideoCodec VideoCodec // empty/h264, auto, or capability-gated hevc AutomaticHEVCAvailable bool // capture preflight found the hardware HEVC-4K path MeasuredVideoLatency time.Duration // measured minimum lead for the local HEVC capture path + MinimumVideoLead time.Duration // known capture/transport lead before codec selection NoEncrypt bool // Disable encryption for debugging DirectKey bool // Use shk/shiv directly without SHA-512 derivation NoAudio bool // Disable audio streaming diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go index 1784951..d5b981b 100644 --- a/internal/airplay/mirror.go +++ b/internal/airplay/mirror.go @@ -504,6 +504,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig var receiverEventPort int var attemptedEventPort int audioControlLPort := audioCtrlConn.LocalAddr().(*net.UDPAddr).Port + if !targetLatencyIsExplicit() { + latencies = latencies.withMinimumVideoLead(cfg.MinimumVideoLead) + } audioLatencySamples := samplesFor44k1(latencies.audio) measuredLatencyApplied := false audioSetupCommitted := false diff --git a/internal/airplay/receiver_server_test.go b/internal/airplay/receiver_server_test.go index c3c28f1..b253850 100644 --- a/internal/airplay/receiver_server_test.go +++ b/internal/airplay/receiver_server_test.go @@ -579,6 +579,37 @@ func TestMediaFirstAutoFallsBackBeforeCreatingLatencyMismatch(t *testing.T) { } } +func TestMediaFirstRelayLeadPreservesAutomaticHEVC(t *testing.T) { + SetTargetLatency(0) + t.Cleanup(func() { SetTargetLatency(0) }) + _, client, ctx := newReceiverServerTestPair(t, ReceiverConfig{ + Profile: ReceiverProfileRoku, DisplayWidth: 3840, DisplayHeight: 2160, + }) + if err := client.Pair(ctx, ""); err != nil { + t.Fatalf("pair: %v", err) + } + session, err := client.SetupMirrorWithVideoCodecPreparation(ctx, StreamConfig{ + VideoCodec: VideoCodecAuto, + AutomaticHEVCAvailable: true, + MinimumVideoLead: 250 * time.Millisecond, + }, func(_, _ int, codec VideoCodec) error { + if codec != VideoCodecHEVC { + return fmt.Errorf("media-first relay codec = %s, want HEVC", codec) + } + return nil + }) + if err != nil { + t.Fatalf("setup mirror: %v", err) + } + defer session.Close() + if session.videoCodec != VideoCodecHEVC || session.timestampBias != 250*time.Millisecond { + t.Fatalf("media-first relay session = codec %s lead %v, want HEVC/250ms", session.videoCodec, session.timestampBias) + } + if session.audioStream == nil || session.audioStream.latencySamples != samplesFor44k1(260*time.Millisecond) { + t.Fatalf("media-first relay audio lead = %#v, want %d samples", session.audioStream, samplesFor44k1(260*time.Millisecond)) + } +} + func TestMediaFirstCalibratedAutoFallsBackBeforeLiveMeasurement(t *testing.T) { SetTargetLatency(0) t.Cleanup(func() { SetTargetLatency(0) }) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f0f9b4f..8207d26 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1135,6 +1135,7 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ streamCfg := d.mirrorStreamConfig() streamCfg.AutomaticHEVCAvailable = capturePreparation.AutomaticHEVCAvailable() streamCfg.MeasuredVideoLatency = capturePreparation.MeasuredVideoLatency() + streamCfg.MinimumVideoLead = capturePreparation.MinimumVideoLead() var broadcast *airplay.BroadcastCapture selectedCaptureKey := videoCaptureKey{maxWidth: -1, maxHeight: -1} prepareVideo := func(width, height int, codec airplay.VideoCodec) (airplay.VideoPreparationResult, error) { From 17c92f7e17a1a3dd992d973bfa441aeebc8bb9d6 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 22:57:33 +0900 Subject: [PATCH 09/15] Gate late sinks on live keyframes Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/capture_broadcast.go | 28 ++++++++++++++++++++++ internal/airplay/capture_broadcast_test.go | 10 ++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/airplay/capture_broadcast.go b/internal/airplay/capture_broadcast.go index 1f1ec61..5b2eb05 100644 --- a/internal/airplay/capture_broadcast.go +++ b/internal/airplay/capture_broadcast.go @@ -96,6 +96,7 @@ type BroadcastSink struct { maxFrameQueueDuration time.Duration backpressure bool blockedProducers int // number waiting for queue handoff; guarded by mu + awaitingRandomAccess bool inputClosed bool // the source ended; drain queue, then return EOF closed bool // explicitly removed; discard queue and return EOF @@ -166,6 +167,7 @@ func (bc *BroadcastCapture) AddSink() *BroadcastSink { s.primer = bc.primer if len(s.primer.AnnexB) > 0 { s.primer.PTS = bc.now() + s.awaitingRandomAccess = true } bc.sinks = append(bc.sinks, s) bc.mu.Unlock() @@ -195,6 +197,7 @@ func (bc *BroadcastCapture) AddBackpressuredSink() (*BroadcastSink, error) { s.primer = bc.primer if len(s.primer.AnnexB) > 0 { s.primer.PTS = bc.now() + s.awaitingRandomAccess = true } bc.sinks = append(bc.sinks, s) bc.mu.Unlock() @@ -343,6 +346,25 @@ func isDecoderPrimer(annexB []byte) bool { return h264SPS && h264PPS && h264IDR || hevcVPS && hevcSPS && hevcPPS && hevcIRAP } +func isRandomAccessUnit(annexB []byte) bool { + for _, nal := range splitAnnexBAccessUnit(annexB) { + raw := stripStartCode(nal) + if len(raw) == 0 { + continue + } + if raw[0]&0x1f == 5 { + return true + } + if len(raw) >= 2 { + nalType := hevcNALType(raw) + if nalType >= 16 && nalType <= 23 { + return true + } + } + } + return false +} + // finish stops accepting sinks, lets existing sinks drain, and only then // publishes BroadcastCapture completion. func (bc *BroadcastCapture) finish(err error) { @@ -456,6 +478,12 @@ func (s *BroadcastSink) enqueueFrame(frame VideoAccessUnit) error { if s.closed || s.inputClosed { return io.ErrClosedPipe } + if s.awaitingRandomAccess { + if !isRandomAccessUnit(frame.AnnexB) { + return nil + } + s.awaitingRandomAccess = false + } if len(s.frameQueue) == 0 && len(frame.AnnexB) > s.maxQueuedBytes { return errBroadcastSinkBacklog } diff --git a/internal/airplay/capture_broadcast_test.go b/internal/airplay/capture_broadcast_test.go index e486843..3aea959 100644 --- a/internal/airplay/capture_broadcast_test.go +++ b/internal/airplay/capture_broadcast_test.go @@ -181,14 +181,20 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { PTS: boundary.PTS.Add(time.Second / 30), } frames <- live + <-reads + randomAccess := VideoAccessUnit{ + AnnexB: []byte{0, 0, 0, 1, 0x65, 0x80}, + PTS: live.PTS.Add(time.Second / 30), + } + frames <- randomAccess close(frames) next, err := sink.ReadVideoAccessUnit() if err != nil { t.Fatalf("read live frame after decoder primer: %v", err) } - if !bytes.Equal(next.AnnexB, live.AnnexB) || !next.PTS.Equal(live.PTS) { - t.Fatalf("live frame after primer = {%x %v}, want {%x %v}", next.AnnexB, next.PTS, live.AnnexB, live.PTS) + if !bytes.Equal(next.AnnexB, randomAccess.AnnexB) || !next.PTS.Equal(randomAccess.PTS) { + t.Fatalf("live frame after primer = {%x %v}, want random access {%x %v}", next.AnnexB, next.PTS, randomAccess.AnnexB, randomAccess.PTS) } if err := <-runDone; !errors.Is(err, io.EOF) { t.Fatalf("broadcast run = %v, want EOF", err) From f9580fa6791075a963b75e04a3a1452bd0485290 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 23:04:46 +0900 Subject: [PATCH 10/15] Remove unsafe late-sink replay Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/capture_broadcast.go | 96 +------------- internal/airplay/capture_broadcast_test.go | 143 --------------------- 2 files changed, 2 insertions(+), 237 deletions(-) diff --git a/internal/airplay/capture_broadcast.go b/internal/airplay/capture_broadcast.go index 5b2eb05..6459624 100644 --- a/internal/airplay/capture_broadcast.go +++ b/internal/airplay/capture_broadcast.go @@ -48,7 +48,6 @@ type BroadcastCapture struct { // adjacent PTS values. A leaky upstream queue can legitimately create large // PTS gaps while only one encoded picture is pending. frameDuration time.Duration - now func() time.Time mu sync.Mutex done chan struct{} err error // set before done is closed @@ -58,11 +57,6 @@ type BroadcastCapture struct { // following sequence, which gives attachment an exact cutover even when a // source read has completed but has not yet been fanned out. sequence uint64 - // Timestamped access-unit fan-out caches the latest complete parameter-set - // plus random-access AU. Legacy byte-stream fan-out retains its exact - // next-read cutover and therefore waits for the encoder's next keyframe. - primer VideoAccessUnit - drainTimeout time.Duration sinks []*BroadcastSink @@ -79,7 +73,6 @@ type BroadcastSink struct { queue [][]byte frameQueue []VideoAccessUnit - primer VideoAccessUnit headOffset int queuedBytes int // queuedFrameDuration is a nominal sample-duration sum. It deliberately does @@ -96,7 +89,6 @@ type BroadcastSink struct { maxFrameQueueDuration time.Duration backpressure bool blockedProducers int // number waiting for queue handoff; guarded by mu - awaitingRandomAccess bool inputClosed bool // the source ended; drain queue, then return EOF closed bool // explicitly removed; discard queue and return EOF @@ -145,7 +137,6 @@ func NewBroadcastCaptureWithFrameRate(src *ScreenCapture, fps int) *BroadcastCap src: src, frames: src != nil && src.frames != nil, frameDuration: time.Second / time.Duration(fps), - now: time.Now, done: make(chan struct{}), drainTimeout: broadcastSinkDrainTimeout, } @@ -164,11 +155,6 @@ func (bc *BroadcastCapture) AddSink() *BroadcastSink { return s } s.startSequence = bc.sequence + 1 - s.primer = bc.primer - if len(s.primer.AnnexB) > 0 { - s.primer.PTS = bc.now() - s.awaitingRandomAccess = true - } bc.sinks = append(bc.sinks, s) bc.mu.Unlock() return s @@ -194,11 +180,6 @@ func (bc *BroadcastCapture) AddBackpressuredSink() (*BroadcastSink, error) { } bc.exclusive = true s.startSequence = bc.sequence + 1 - s.primer = bc.primer - if len(s.primer.AnnexB) > 0 { - s.primer.PTS = bc.now() - s.awaitingRandomAccess = true - } bc.sinks = append(bc.sinks, s) bc.mu.Unlock() return s, nil @@ -286,12 +267,6 @@ func (bc *BroadcastCapture) runFrames() error { frame, readErr := bc.src.ReadVideoAccessUnit() if len(frame.AnnexB) > 0 { bc.mu.Lock() - if len(frame.AnnexB) <= broadcastSinkQueueBytes && isDecoderPrimer(frame.AnnexB) { - bc.primer = VideoAccessUnit{ - AnnexB: append([]byte(nil), frame.AnnexB...), - PTS: frame.PTS, - } - } sinks := make([]*BroadcastSink, 0, len(bc.sinks)) for _, sink := range bc.sinks { if sink.startSequence <= sequence { @@ -313,58 +288,6 @@ func (bc *BroadcastCapture) runFrames() error { } } -func isDecoderPrimer(annexB []byte) bool { - var h264SPS, h264PPS, h264IDR bool - var hevcVPS, hevcSPS, hevcPPS, hevcIRAP bool - for _, nal := range splitAnnexBAccessUnit(annexB) { - raw := stripStartCode(nal) - if len(raw) == 0 { - continue - } - switch raw[0] & 0x1f { - case 5: - h264IDR = true - case 7: - h264SPS = true - case 8: - h264PPS = true - } - if len(raw) < 2 { - continue - } - switch nalType := hevcNALType(raw); nalType { - case 32: - hevcVPS = true - case 33: - hevcSPS = true - case 34: - hevcPPS = true - default: - hevcIRAP = hevcIRAP || nalType >= 16 && nalType <= 23 - } - } - return h264SPS && h264PPS && h264IDR || hevcVPS && hevcSPS && hevcPPS && hevcIRAP -} - -func isRandomAccessUnit(annexB []byte) bool { - for _, nal := range splitAnnexBAccessUnit(annexB) { - raw := stripStartCode(nal) - if len(raw) == 0 { - continue - } - if raw[0]&0x1f == 5 { - return true - } - if len(raw) >= 2 { - nalType := hevcNALType(raw) - if nalType >= 16 && nalType <= 23 { - return true - } - } - } - return false -} - // finish stops accepting sinks, lets existing sinks drain, and only then // publishes BroadcastCapture completion. func (bc *BroadcastCapture) finish(err error) { @@ -478,12 +401,6 @@ func (s *BroadcastSink) enqueueFrame(frame VideoAccessUnit) error { if s.closed || s.inputClosed { return io.ErrClosedPipe } - if s.awaitingRandomAccess { - if !isRandomAccessUnit(frame.AnnexB) { - return nil - } - s.awaitingRandomAccess = false - } if len(s.frameQueue) == 0 && len(frame.AnnexB) > s.maxQueuedBytes { return errBroadcastSinkBacklog } @@ -506,7 +423,7 @@ func (s *BroadcastSink) enqueueFrame(frame VideoAccessUnit) error { } func (s *BroadcastSink) queueEmptyLocked() bool { - return len(s.queue) == 0 && len(s.frameQueue) == 0 && len(s.primer.AnnexB) == 0 + return len(s.queue) == 0 && len(s.frameQueue) == 0 } // finish marks source EOF without discarding data already queued. @@ -536,7 +453,6 @@ func (s *BroadcastSink) abort() { s.frameQueue[i].AnnexB = nil } s.frameQueue = nil - s.primer = VideoAccessUnit{} s.headOffset = 0 s.queuedBytes = 0 s.queuedFrameDuration = 0 @@ -598,20 +514,12 @@ func (s *BroadcastSink) Read(p []byte) (int, error) { func (s *BroadcastSink) ReadVideoAccessUnit() (VideoAccessUnit, error) { s.mu.Lock() defer s.mu.Unlock() - for len(s.primer.AnnexB) == 0 && len(s.frameQueue) == 0 && !s.inputClosed && !s.closed { + for len(s.frameQueue) == 0 && !s.inputClosed && !s.closed { s.cond.Wait() } if s.closed { return VideoAccessUnit{}, io.EOF } - if len(s.primer.AnnexB) > 0 { - primer := s.primer - s.primer = VideoAccessUnit{} - if s.inputClosed && len(s.frameQueue) == 0 && len(s.queue) == 0 { - s.closeDoneLocked() - } - return primer, nil - } if len(s.frameQueue) == 0 { s.closeDoneLocked() return VideoAccessUnit{}, io.EOF diff --git a/internal/airplay/capture_broadcast_test.go b/internal/airplay/capture_broadcast_test.go index 3aea959..616b3f5 100644 --- a/internal/airplay/capture_broadcast_test.go +++ b/internal/airplay/capture_broadcast_test.go @@ -18,11 +18,6 @@ type channelVideoAccessUnitReader struct { frames <-chan VideoAccessUnit } -type signaledVideoAccessUnitReader struct { - frames <-chan VideoAccessUnit - reads chan<- struct{} -} - func waitForBroadcastSinkState(t *testing.T, sink *BroadcastSink, predicate func(*BroadcastSink) bool, description string) { t.Helper() deadline := time.Now().Add(time.Second) @@ -55,15 +50,6 @@ func (r *channelVideoAccessUnitReader) ReadVideoAccessUnit() (VideoAccessUnit, e return frame, nil } -func (r *signaledVideoAccessUnitReader) ReadVideoAccessUnit() (VideoAccessUnit, error) { - r.reads <- struct{}{} - frame, ok := <-r.frames - if !ok { - return VideoAccessUnit{}, io.EOF - } - return frame, nil -} - func (r *sliceVideoAccessUnitReader) ReadVideoAccessUnit() (VideoAccessUnit, error) { if r.index == len(r.frames) { return VideoAccessUnit{}, io.EOF @@ -115,135 +101,6 @@ func TestBroadcastCapturePreservesTimestampedAccessUnits(t *testing.T) { } } -func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) { - frames := make(chan VideoAccessUnit) - reads := make(chan struct{}, 4) - capture := &ScreenCapture{ - frames: &signaledVideoAccessUnitReader{frames: frames, reads: reads}, - waitCh: make(chan struct{}), - } - broadcast := NewBroadcastCaptureWithFrameRate(capture, 30) - replayPTS := time.Unix(110, 0) - broadcast.now = func() time.Time { return replayPTS } - runDone := make(chan error, 1) - go func() { runDone <- broadcast.Run() }() - - <-reads - primer := VideoAccessUnit{ - AnnexB: []byte{ - 0, 0, 0, 1, 0x67, 0x42, 0x00, 0x1f, - 0, 0, 0, 1, 0x68, 0xce, 0x06, 0xe2, - 0, 0, 0, 1, 0x65, 0x80, - }, - PTS: time.Unix(100, 0), - } - frames <- primer - <-reads - - sink := broadcast.AddSink() - defer sink.Close() - replayDone := make(chan struct { - frame VideoAccessUnit - err error - }, 1) - go func() { - frame, err := sink.ReadVideoAccessUnit() - replayDone <- struct { - frame VideoAccessUnit - err error - }{frame: frame, err: err} - }() - var replayed VideoAccessUnit - select { - case result := <-replayDone: - if result.err != nil { - t.Fatalf("read replayed decoder primer: %v", result.err) - } - replayed = result.frame - case <-time.After(time.Second): - t.Fatal("cached decoder primer was not available immediately") - } - if !bytes.Equal(replayed.AnnexB, primer.AnnexB) { - t.Fatalf("first late-sink frame = %x, want cached decoder primer %x", replayed.AnnexB, primer.AnnexB) - } - if !replayed.PTS.Equal(replayPTS) { - t.Fatalf("replayed primer PTS = %v, want attachment PTS %v", replayed.PTS, replayPTS) - } - - boundary := VideoAccessUnit{ - AnnexB: []byte{0, 0, 0, 1, 0x61, 0x40}, - PTS: primer.PTS.Add(10 * time.Second), - } - frames <- boundary - <-reads - live := VideoAccessUnit{ - AnnexB: []byte{0, 0, 0, 1, 0x61, 0x80}, - PTS: boundary.PTS.Add(time.Second / 30), - } - frames <- live - <-reads - randomAccess := VideoAccessUnit{ - AnnexB: []byte{0, 0, 0, 1, 0x65, 0x80}, - PTS: live.PTS.Add(time.Second / 30), - } - frames <- randomAccess - close(frames) - - next, err := sink.ReadVideoAccessUnit() - if err != nil { - t.Fatalf("read live frame after decoder primer: %v", err) - } - if !bytes.Equal(next.AnnexB, randomAccess.AnnexB) || !next.PTS.Equal(randomAccess.PTS) { - t.Fatalf("live frame after primer = {%x %v}, want random access {%x %v}", next.AnnexB, next.PTS, randomAccess.AnnexB, randomAccess.PTS) - } - if err := <-runDone; !errors.Is(err, io.EOF) { - t.Fatalf("broadcast run = %v, want EOF", err) - } -} - -func TestBroadcastCapturePrimerSnapshotSurvivesRefresh(t *testing.T) { - frames := make(chan VideoAccessUnit) - reads := make(chan struct{}, 4) - capture := &ScreenCapture{ - frames: &signaledVideoAccessUnitReader{frames: frames, reads: reads}, - waitCh: make(chan struct{}), - } - broadcast := NewBroadcastCapture(capture) - broadcast.now = func() time.Time { return time.Unix(120, 0) } - runDone := make(chan error, 1) - go func() { runDone <- broadcast.Run() }() - - first := VideoAccessUnit{AnnexB: []byte{ - 0, 0, 0, 1, 0x67, 0x42, - 0, 0, 0, 1, 0x68, 0xce, - 0, 0, 0, 1, 0x65, 0xaa, - }} - second := VideoAccessUnit{AnnexB: []byte{ - 0, 0, 0, 1, 0x67, 0x64, - 0, 0, 0, 1, 0x68, 0xee, - 0, 0, 0, 1, 0x65, 0xbb, - }} - <-reads - frames <- first - <-reads - sink := broadcast.AddSink() - defer sink.Close() - frames <- second - <-reads - - got, err := sink.ReadVideoAccessUnit() - if err != nil { - t.Fatalf("read primer snapshot: %v", err) - } - if !bytes.Equal(got.AnnexB, first.AnnexB) { - t.Fatalf("primer snapshot = %x, want %x", got.AnnexB, first.AnnexB) - } - close(frames) - if err := <-runDone; !errors.Is(err, io.EOF) { - t.Fatalf("broadcast run = %v, want EOF", err) - } -} - func TestBroadcastSinkBackpressuresWithOnePendingAccessUnit(t *testing.T) { sink := newBroadcastSinkWithPolicy(nil, true) base := time.Now() From 0f38ba624364f1ea1ae1ef9a4bce438f8602c4df Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Fri, 28 Aug 2026 23:44:13 +0900 Subject: [PATCH 11/15] Tolerate transient AirPlay write stalls Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/capture_broadcast.go | 15 +++++++---- internal/airplay/capture_broadcast_test.go | 31 ++++++++++++++++------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/internal/airplay/capture_broadcast.go b/internal/airplay/capture_broadcast.go index 6459624..c02f5e9 100644 --- a/internal/airplay/capture_broadcast.go +++ b/internal/airplay/capture_broadcast.go @@ -19,6 +19,12 @@ const ( // metadata; it is deliberately generous for normal H.264 buffer cadence. broadcastSinkQueueChunks = 4096 + // Encoded frames are detached from the portal-owned raw-buffer pool. Keep + // enough of them to absorb a receiver's ordinary two-second socket-write + // stall without terminating the session; the independent byte limit still + // bounds memory and a persistently stalled peer is removed. + broadcastSinkQueueDuration = 2 * time.Second + // Source shutdown normally races with consumers draining their last few // buffers. Do not let a receiver that stopped reading keep Run alive forever. broadcastSinkDrainTimeout = 2 * time.Second @@ -82,10 +88,9 @@ type BroadcastSink struct { maxQueuedBytes int maxQueuedChunks int - // Apple's ordinary virtual-display source bounds its upstream frame queue to - // 67 ms and drops an incoming source frame at that limit. Doubletake derives - // a downstream encoded-relay ceiling from that value and counts configured - // sample durations. The byte and chunk limits remain independent safeguards. + // This encoded system-memory queue is independent of Apple's upstream raw + // frame queue. It counts nominal sample durations so PTS gaps caused by raw + // frame dropping do not spuriously detach a healthy receiver. maxFrameQueueDuration time.Duration backpressure bool blockedProducers int // number waiting for queue handoff; guarded by mu @@ -106,7 +111,7 @@ func newBroadcastSinkWithPolicy(owner *BroadcastCapture, backpressure bool) *Bro owner: owner, maxQueuedBytes: broadcastSinkQueueBytes, maxQueuedChunks: broadcastSinkQueueChunks, - maxFrameQueueDuration: ordinaryScreenFrameQueueDuration, + maxFrameQueueDuration: broadcastSinkQueueDuration, backpressure: backpressure, frameDuration: frameDuration, done: make(chan struct{}), diff --git a/internal/airplay/capture_broadcast_test.go b/internal/airplay/capture_broadcast_test.go index 616b3f5..4028b0f 100644 --- a/internal/airplay/capture_broadcast_test.go +++ b/internal/airplay/capture_broadcast_test.go @@ -192,15 +192,16 @@ func TestBroadcastSinkNonblockingFrameQueueUsesNominalDuration(t *testing.T) { base := time.Now() // A large or backward PTS gap can be caused by the upstream leaky queue; it // must not turn one queued picture into an artificial duration overflow. - for i, offset := range []time.Duration{0, time.Second} { + for i := 0; i < 60; i++ { + offset := time.Duration(i%2) * time.Second frame := VideoAccessUnit{AnnexB: []byte{byte(i + 1)}, PTS: base.Add(offset)} if err := sink.enqueueFrame(frame); err != nil { t.Fatalf("enqueue frame %d at %v: %v", i, offset, err) } } - third := VideoAccessUnit{AnnexB: []byte{3}, PTS: base.Add(-time.Second)} - if err := sink.enqueueFrame(third); !errors.Is(err, errBroadcastSinkBacklog) { - t.Fatalf("enqueue third nominal 30fps frame = %v, want backlog error", err) + overflow := VideoAccessUnit{AnnexB: []byte{0xff}, PTS: base.Add(-time.Second)} + if err := sink.enqueueFrame(overflow); !errors.Is(err, errBroadcastSinkBacklog) { + t.Fatalf("enqueue frame after 2s nominal 30fps queue = %v, want backlog error", err) } } @@ -210,8 +211,8 @@ func TestBroadcastSinkNominalDurationUsesConfiguredFrameRate(t *testing.T) { acceptedFrames int rejectedOrdinal int }{ - {fps: 20, acceptedFrames: 1, rejectedOrdinal: 2}, - {fps: 60, acceptedFrames: 4, rejectedOrdinal: 5}, + {fps: 20, acceptedFrames: 40, rejectedOrdinal: 41}, + {fps: 60, acceptedFrames: 120, rejectedOrdinal: 121}, } { t.Run(fmt.Sprintf("%dfps", test.fps), func(t *testing.T) { broadcast := NewBroadcastCaptureWithFrameRate(nil, test.fps) @@ -363,7 +364,7 @@ func TestBackpressuredByteBroadcastHandoff(t *testing.T) { func TestLoneSharedTimestampedSinkDoesNotBackpressureCapture(t *testing.T) { base := time.Now() - frames := make([]VideoAccessUnit, 4) + frames := make([]VideoAccessUnit, 62) for i := range frames { frames[i] = VideoAccessUnit{ AnnexB: []byte{byte(i + 1)}, @@ -396,6 +397,20 @@ func TestLoneSharedTimestampedSinkDoesNotBackpressureCapture(t *testing.T) { } } +func TestSharedTimestampedSinkToleratesTransientNetworkStall(t *testing.T) { + sink := newBroadcastSinkWithPolicy(nil, false) + base := time.Now() + for i := 0; i < 15; i++ { + frame := VideoAccessUnit{ + AnnexB: []byte{byte(i + 1)}, + PTS: base.Add(time.Duration(i) * time.Second / 30), + } + if err := sink.enqueueFrame(frame); err != nil { + t.Fatalf("enqueue frame %d during 500ms network stall: %v", i, err) + } + } +} + func TestTimestampedSlowSinkDoesNotStallHealthyPeer(t *testing.T) { frames := make(chan VideoAccessUnit) capture := &ScreenCapture{ @@ -424,7 +439,7 @@ func TestTimestampedSlowSinkDoesNotStallHealthyPeer(t *testing.T) { }() base := time.Now() - for i := 0; i < 6; i++ { + for i := 0; i < 62; i++ { want := VideoAccessUnit{ AnnexB: []byte{byte(i + 1)}, PTS: base.Add(time.Duration(i) * time.Second / 30), From d6fb969a058056ba39b3e435df3a56de3c268ffd Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Sat, 29 Aug 2026 02:22:48 +0900 Subject: [PATCH 12/15] Make restore-token reset atomic Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/credentials.go | 73 ++++++++++++++++++++- internal/airplay/credentials_clear_test.go | 44 +++++++++++++ internal/daemon/daemon.go | 69 +++++++++++-------- internal/daemon/reset_restore_token.go | 30 +++++++-- internal/daemon/reset_restore_token_test.go | 70 +++++++++++++++++++- 5 files changed, 250 insertions(+), 36 deletions(-) diff --git a/internal/airplay/credentials.go b/internal/airplay/credentials.go index ace1d61..1fe685f 100644 --- a/internal/airplay/credentials.go +++ b/internal/airplay/credentials.go @@ -103,6 +103,16 @@ type CredentialStore struct { backend CredentialBackend } +// RestoreTokenReset is a one-shot restore-token deletion which can be rolled +// back if the daemon loses its stream reservation before reconnecting. +type RestoreTokenReset struct { + store *CredentialStore + deviceID string + previousToken string + changed bool + done bool +} + // NewCredentialStore creates a credential store backed by a JSON file at path. func NewCredentialStore(path string) (*CredentialStore, error) { fb, err := newFileBackend(path) @@ -196,19 +206,76 @@ func (cs *CredentialStore) SaveRestoreToken(deviceID, restoreToken string) error // ClearRestoreToken removes only the Wayland screencast restore token for a // device. Pairing credentials and all other device entries are preserved. func (cs *CredentialStore) ClearRestoreToken(deviceID string) error { + reset, err := cs.BeginRestoreTokenReset(deviceID) + if err != nil { + return err + } + reset.Commit() + return nil +} + +// BeginRestoreTokenReset clears the restore token while retaining enough state +// to restore it if the caller cannot commit the corresponding reconnect. +func (cs *CredentialStore) BeginRestoreTokenReset(deviceID string) (*RestoreTokenReset, error) { cs.mu.Lock() defer cs.mu.Unlock() + reset := &RestoreTokenReset{store: cs, deviceID: deviceID} creds, err := cs.backend.Lookup(deviceID) if err != nil { - return err + return nil, err } if creds == nil || creds.RestoreToken == "" { - return nil + return reset, nil } + reset.previousToken = creds.RestoreToken + reset.changed = true updated := *creds updated.RestoreToken = "" - return cs.backend.Save(deviceID, &updated) + if err := cs.backend.Save(deviceID, &updated); err != nil { + return nil, err + } + return reset, nil +} + +// Commit makes a successful deletion permanent. +func (r *RestoreTokenReset) Commit() { + if r == nil { + return + } + r.store.mu.Lock() + r.done = true + r.store.mu.Unlock() +} + +// Rollback restores only the previous token onto a fresh credential snapshot. +// A concurrently saved non-empty token wins, and all other fields are retained. +func (r *RestoreTokenReset) Rollback() error { + if r == nil { + return nil + } + r.store.mu.Lock() + defer r.store.mu.Unlock() + if r.done || !r.changed { + r.done = true + return nil + } + creds, err := r.store.backend.Lookup(r.deviceID) + if err != nil { + return err + } + if creds == nil { + creds = &SavedCredentials{} + } + if creds.RestoreToken == "" { + updated := *creds + updated.RestoreToken = r.previousToken + if err := r.store.backend.Save(r.deviceID, &updated); err != nil { + return err + } + } + r.done = true + return nil } // fileBackend stores credentials as a JSON file on disk. diff --git a/internal/airplay/credentials_clear_test.go b/internal/airplay/credentials_clear_test.go index 9a1c6ee..53d1ceb 100644 --- a/internal/airplay/credentials_clear_test.go +++ b/internal/airplay/credentials_clear_test.go @@ -111,3 +111,47 @@ func TestCredentialStoreClearRestoreTokenUsesBackendWithoutDeletingEntry(t *test t.Fatalf("backend credentials after clear = %+v", creds) } } + +func TestRestoreTokenResetRollbackMergesConcurrentCredentialChanges(t *testing.T) { + backend := &recordingCredentialBackend{devices: map[string]*SavedCredentials{ + "device-1": {PairingID: "pair-1", RestoreToken: "restore-1"}, + }} + store := NewCredentialStoreWithBackend(backend) + + reset, err := store.BeginRestoreTokenReset("device-1") + if err != nil { + t.Fatalf("BeginRestoreTokenReset: %v", err) + } + backend.devices["device-1"] = &SavedCredentials{ + PairingID: "pair-2", + RestoreToken: "", + } + + if err := reset.Rollback(); err != nil { + t.Fatalf("Rollback: %v", err) + } + creds := backend.devices["device-1"] + if creds.PairingID != "pair-2" || creds.RestoreToken != "restore-1" { + t.Fatalf("credentials after rollback = %+v", creds) + } +} + +func TestRestoreTokenResetRollbackDoesNotOverwriteConcurrentToken(t *testing.T) { + backend := &recordingCredentialBackend{devices: map[string]*SavedCredentials{ + "device-1": {RestoreToken: "restore-1"}, + }} + store := NewCredentialStoreWithBackend(backend) + + reset, err := store.BeginRestoreTokenReset("device-1") + if err != nil { + t.Fatalf("BeginRestoreTokenReset: %v", err) + } + backend.devices["device-1"] = &SavedCredentials{RestoreToken: "restore-2"} + + if err := reset.Rollback(); err != nil { + t.Fatalf("Rollback: %v", err) + } + if got := backend.devices["device-1"].RestoreToken; got != "restore-2" { + t.Fatalf("restore token after rollback = %q, want concurrent token", got) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 8207d26..f19b580 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1354,24 +1354,34 @@ func (d *Daemon) getOrStartPreparedCaptureGroup(ctx context.Context, entry *acti if d.captureGroups == nil { d.captureGroups = make(map[videoCaptureKey]*videoCaptureGroup) } - if group := d.captureGroups[key]; group != nil { + group := d.captureGroups[key] + var captureCtx context.Context + var captureCancel context.CancelFunc + if group != nil { if group.resetReservedBy != nil && group.resetReservedBy != entry { d.mu.Unlock() return nil, 0, fmt.Errorf("%w: %dx%d", errCaptureGroupResetReserved, key.maxWidth, key.maxHeight) } - entry.captureGroup = group - broadcast := group.broadcast - d.mu.Unlock() - preparation.Close() - if broadcast == nil { - return nil, 0, fmt.Errorf("capture group %dx%d has no broadcast", key.maxWidth, key.maxHeight) + if group.resetReservedBy == entry && group.broadcast == nil && group.capture == nil && group.cancel == nil { + captureCtx, captureCancel = context.WithCancel(context.Background()) + group.cancel = captureCancel + group.resetReservedBy = nil + } else { + entry.captureGroup = group + broadcast := group.broadcast + d.mu.Unlock() + preparation.Close() + if broadcast == nil { + return nil, 0, fmt.Errorf("capture group %dx%d has no broadcast", key.maxWidth, key.maxHeight) + } + return broadcast, group.minimumVideoLead, nil } - return broadcast, group.minimumVideoLead, nil + } else { + captureCtx, captureCancel = context.WithCancel(context.Background()) + group = &videoCaptureGroup{key: key, cancel: captureCancel} + d.captureGroups[key] = group + entry.captureGroup = group } - - captureCtx, captureCancel := context.WithCancel(context.Background()) - group := &videoCaptureGroup{key: key, cancel: captureCancel} - d.captureGroups[key] = group entry.captureGroup = group d.mu.Unlock() @@ -1464,26 +1474,33 @@ func (d *Daemon) getOrStartCaptureGroup(entry *activeStream, restoreToken, devic if d.captureGroups == nil { d.captureGroups = make(map[videoCaptureKey]*videoCaptureGroup) } - if group := d.captureGroups[key]; group != nil { + group := d.captureGroups[key] + var captureCtx context.Context + var captureCancel context.CancelFunc + if group != nil { if group.resetReservedBy != nil && group.resetReservedBy != entry { d.mu.Unlock() return nil, fmt.Errorf("%w: %dx%d", errCaptureGroupResetReserved, key.maxWidth, key.maxHeight) } - entry.captureGroup = group - broadcast := group.broadcast - d.mu.Unlock() - if broadcast == nil { - return nil, fmt.Errorf("capture group %dx%d has no broadcast", key.maxWidth, key.maxHeight) + if group.resetReservedBy == entry && group.broadcast == nil && group.capture == nil && group.cancel == nil { + captureCtx, captureCancel = context.WithCancel(context.Background()) + group.cancel = captureCancel + group.resetReservedBy = nil + } else { + entry.captureGroup = group + broadcast := group.broadcast + d.mu.Unlock() + if broadcast == nil { + return nil, fmt.Errorf("capture group %dx%d has no broadcast", key.maxWidth, key.maxHeight) + } + return broadcast, nil } - return broadcast, nil + } else { + captureCtx, captureCancel = context.WithCancel(context.Background()) + group = &videoCaptureGroup{key: key, cancel: captureCancel} + d.captureGroups[key] = group + entry.captureGroup = group } - - // Publish the group and cancellation hook before entering the display portal - // or launching GStreamer. A targeted disconnect can then cancel an orphaned - // startup without affecting captures used by other canvas groups. - captureCtx, captureCancel := context.WithCancel(context.Background()) - group := &videoCaptureGroup{key: key, cancel: captureCancel} - d.captureGroups[key] = group entry.captureGroup = group d.mu.Unlock() diff --git a/internal/daemon/reset_restore_token.go b/internal/daemon/reset_restore_token.go index eeb5c3c..a7e01c6 100644 --- a/internal/daemon/reset_restore_token.go +++ b/internal/daemon/reset_restore_token.go @@ -58,25 +58,35 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { d.streamWorkers.Add(1) d.mu.Unlock() - clearErr := d.credStore.ClearRestoreToken(deviceID) + credentialReset, clearErr := d.credStore.BeginRestoreTokenReset(deviceID) d.mu.Lock() reservationCurrent := d.restoreTokenResetReservationCurrentLocked(target, deviceID, port, entry, group) - if group.resetReservedBy == entry { - group.resetReservedBy = nil - } state := d.overallStateLocked() if d.shuttingDown { + if group.resetReservedBy == entry { + group.resetReservedBy = nil + } d.mu.Unlock() + if clearErr == nil { + _ = credentialReset.Rollback() + } d.streamWorkers.Done() return Response{OK: false, State: state, Error: "daemon is shutting down"} } if !reservationCurrent { + if group.resetReservedBy == entry { + group.resetReservedBy = nil + } d.mu.Unlock() + if clearErr == nil { + _ = credentialReset.Rollback() + } d.streamWorkers.Done() return Response{OK: false, State: state, Error: "restore token reset was canceled for " + target} } if clearErr != nil { + group.resetReservedBy = nil d.mu.Unlock() d.streamWorkers.Done() return Response{OK: false, State: state, Error: "clear restore token: " + clearErr.Error()} @@ -91,7 +101,17 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { cancelFn: cancel, credentialCh: make(chan string, 1), } + // Replace the old generation with an owner-only claim before releasing the + // daemon lock. Peers cannot create or join this key while physical cleanup + // runs; the designated replacement later converts the claim into a capture. + group.resetReservedBy = nil cleanup := d.detachStreamLocked(target) + reservation := &videoCaptureGroup{ + key: group.key, + resetReservedBy: replacement, + } + replacement.captureGroup = reservation + d.captureGroups[group.key] = reservation d.streams[target] = replacement d.mu.Unlock() @@ -112,6 +132,7 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { d.mu.Unlock() abandoned.run() cancel() + _ = credentialReset.Rollback() d.streamWorkers.Done() if shuttingDown { return Response{OK: false, State: state, Error: "daemon is shutting down"} @@ -122,6 +143,7 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { state = d.overallStateLocked() d.mu.Unlock() + credentialReset.Commit() go func() { defer d.streamWorkers.Done() d.connectAndStream(connCtx, replacement, target, port, "") diff --git a/internal/daemon/reset_restore_token_test.go b/internal/daemon/reset_restore_token_test.go index d4c5608..f2afdd1 100644 --- a/internal/daemon/reset_restore_token_test.go +++ b/internal/daemon/reset_restore_token_test.go @@ -240,6 +240,59 @@ func TestResetRestoreTokenReservationExcludesCaptureGroupJoin(t *testing.T) { } } +func TestResetRestoreTokenReservationSurvivesPhysicalCaptureCleanup(t *testing.T) { + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{RestoreToken: "restore-1"}, + } + d, entry, oldGroup, _ := newResetTestDaemon(t, backend) + cleanupStarted := make(chan struct{}) + cleanupRelease := make(chan struct{}) + var releaseOnce sync.Once + releaseCleanup := func() { releaseOnce.Do(func() { close(cleanupRelease) }) } + originalCancel := entry.cancelFn + entry.cancelFn = func() { + close(cleanupStarted) + <-cleanupRelease + originalCancel() + } + peer := &activeStream{deviceIP: "192.0.2.11", state: StateConnecting} + d.streams[peer.deviceIP] = peer + defer d.Shutdown() + defer releaseCleanup() + + resetResponse := make(chan Response, 1) + go func() { + resetResponse <- d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + }() + waitForResetSignal(t, cleanupStarted, "old capture cleanup did not start") + + d.mu.Lock() + replacement := d.streams[resetTestTarget] + reservation := d.captureGroups[oldGroup.key] + reserved := reservation != nil && reservation != oldGroup && + reservation.resetReservedBy == replacement && + replacement.captureGroup == reservation + d.mu.Unlock() + if !reserved { + t.Fatal("replacement did not retain an exclusive capture-key reservation during cleanup") + } + + _, _, joinErr := d.getOrStartPreparedCaptureGroup( + context.Background(), peer, nil, 1920, 1080, airplay.VideoCodecH264, + ) + if joinErr == nil || !strings.Contains(joinErr.Error(), "reserved for restore-token reset") { + t.Fatalf("capture join during cleanup = %v, want reset reservation rejection", joinErr) + } + d.mu.Lock() + delete(d.streams, peer.deviceIP) + d.mu.Unlock() + + releaseCleanup() + if response := waitForResetResponse(t, resetResponse); !response.OK { + t.Fatalf("reset response = %+v", response) + } +} + func TestResetRestoreTokenReservationMakesShutdownWait(t *testing.T) { saveStarted := make(chan struct{}) saveRelease := make(chan struct{}) @@ -294,6 +347,9 @@ func TestResetRestoreTokenReservationMakesShutdownWait(t *testing.T) { if response.OK || !strings.Contains(response.Error, "shutting down") || response.State != StateIdle { t.Fatalf("reset response during shutdown = %+v", response) } + if credentials := d.credStore.Lookup("device-1"); credentials == nil || credentials.RestoreToken != "restore-1" { + t.Fatalf("shutdown cancellation lost restore token: %+v", credentials) + } waitForResetSignal(t, shutdownDone, "Shutdown did not finish after reset released its reservation") } @@ -343,6 +399,9 @@ func TestResetRestoreTokenConcurrentDisconnectReportsCancellationAndOverallState if strings.Contains(response.Error, "shutting down") || response.State != StateStreaming { t.Fatalf("reset misreported concurrent disconnect: %+v", response) } + if credentials := d.credStore.Lookup("device-1"); credentials == nil || credentials.RestoreToken != "restore-1" { + t.Fatalf("disconnect cancellation lost restore token: %+v", credentials) + } } func TestResetRestoreTokenClearsExclusiveTargetAndReconnectsActualPort(t *testing.T) { @@ -406,16 +465,20 @@ func TestResetRestoreTokenClearsExclusiveTargetAndReconnectsActualPort(t *testin } d.mu.Lock() replacement := d.streams[old.deviceIP] - groupStillPresent := d.captureGroups[group.key] != nil + replacementGroup := d.captureGroups[group.key] + oldGroupStillPresent := replacementGroup == group independentPreserved := d.streams[independent.deviceIP] == independent && d.captureGroups[independentGroup.key] == independentGroup d.mu.Unlock() if replacement == nil || replacement == old || replacement.port != address.Port { t.Fatalf("replacement stream = %+v, want new entry on port %d", replacement, address.Port) } - if groupStillPresent { + if oldGroupStillPresent { t.Fatal("exclusive old capture group remained active") } + if replacementGroup != nil && replacement.captureGroup != replacementGroup { + t.Fatal("replacement did not own the reserved capture generation") + } if !independentPreserved { t.Fatal("reset changed an independent stream or capture group") } @@ -435,6 +498,7 @@ type controlledCredentialBackend struct { saveErr error saveStarted chan struct{} saveRelease <-chan struct{} + saveOnce sync.Once } func (b *controlledCredentialBackend) Lookup(string) (*airplay.SavedCredentials, error) { @@ -452,7 +516,7 @@ func (b *controlledCredentialBackend) Lookup(string) (*airplay.SavedCredentials, func (b *controlledCredentialBackend) Save(_ string, credentials *airplay.SavedCredentials) error { if b.saveStarted != nil { - close(b.saveStarted) + b.saveOnce.Do(func() { close(b.saveStarted) }) } if b.saveRelease != nil { <-b.saveRelease From d9f16259fea2fd4dfc2c68c942370fbad6970193 Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Sat, 29 Aug 2026 02:22:48 +0900 Subject: [PATCH 13/15] Close split capture pipes on startup failure Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/capture.go | 110 +++++++++++++++++++++++++------ internal/airplay/capture_test.go | 46 +++++++++++++ 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/internal/airplay/capture.go b/internal/airplay/capture.go index 46e172e..f36eab5 100644 --- a/internal/airplay/capture.go +++ b/internal/airplay/capture.go @@ -907,6 +907,76 @@ func buildSplitGstVideoPipeline(source gstStage, beforeConvert, afterScale []gst return producer, consumer } +type waylandSplitPipes struct { + rawFrames *os.File + sourceOutput *os.File + sourceStderr *os.File + sourceErrOut *os.File + stdout *os.File + encoderOutput *os.File + encoderStderr *os.File + encoderErrOut *os.File +} + +func openWaylandSplitPipes(sourceCmd, encoderCmd *exec.Cmd) (*waylandSplitPipes, error) { + pipes := &waylandSplitPipes{} + var err error + pipes.rawFrames, pipes.sourceOutput, err = os.Pipe() + if err != nil { + return nil, fmt.Errorf("capture serialization pipe: %w", err) + } + sourceCmd.Stdout = pipes.sourceOutput + pipes.sourceStderr, pipes.sourceErrOut, err = os.Pipe() + if err != nil { + pipes.close() + return nil, fmt.Errorf("capture stderr pipe: %w", err) + } + sourceCmd.Stderr = pipes.sourceErrOut + encoderCmd.Stdin = pipes.rawFrames + pipes.stdout, pipes.encoderOutput, err = os.Pipe() + if err != nil { + pipes.close() + return nil, fmt.Errorf("encoder stdout pipe: %w", err) + } + encoderCmd.Stdout = pipes.encoderOutput + pipes.encoderStderr, pipes.encoderErrOut, err = os.Pipe() + if err != nil { + pipes.close() + return nil, fmt.Errorf("encoder stderr pipe: %w", err) + } + encoderCmd.Stderr = pipes.encoderErrOut + return pipes, nil +} + +func (pipes *waylandSplitPipes) close() { + if pipes == nil { + return + } + for _, closer := range []io.Closer{ + pipes.rawFrames, + pipes.sourceOutput, + pipes.sourceStderr, + pipes.sourceErrOut, + pipes.stdout, + pipes.encoderOutput, + pipes.encoderStderr, + pipes.encoderErrOut, + } { + if closer != nil { + _ = closer.Close() + } + } +} + +func startWaylandEncoder(cmd *exec.Cmd, pipes *waylandSplitPipes) (<-chan error, error) { + wait, err := startGStreamerCommand(cmd) + if err != nil { + pipes.close() + return nil, err + } + return wait, nil +} + func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoderParts encoderResult, nodeID uint32, pwFd *os.File, dbusConn *dbus.Conn, streamSize [2]int, timestampedOutput bool) (*ScreenCapture, error) { if pwFd == nil || dbusConn == nil { if pwFd != nil { @@ -977,60 +1047,58 @@ func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoder sourceCmd := exec.CommandContext(captureCtx, "gst-launch-1.0", sourceArgs...) sourceCmd.ExtraFiles = []*os.File{pwFd} - rawFrames, err := sourceCmd.StdoutPipe() - if err != nil { - cancel() - _ = pwFd.Close() - _ = dbusConn.Close() - return nil, fmt.Errorf("capture serialization pipe: %w", err) - } - sourceStderr, _ := sourceCmd.StderrPipe() - cmd := exec.CommandContext(captureCtx, "gst-launch-1.0", encoderArgs...) - cmd.Stdin = rawFrames - stdout, err := cmd.StdoutPipe() + pipes, err := openWaylandSplitPipes(sourceCmd, cmd) if err != nil { cancel() - _ = rawFrames.Close() _ = pwFd.Close() _ = dbusConn.Close() - return nil, fmt.Errorf("encoder stdout pipe: %w", err) + return nil, err } - encoderStderr, _ := cmd.StderrPipe() - encoderWait, err := startGStreamerCommand(cmd) + encoderWait, err := startWaylandEncoder(cmd, pipes) if err != nil { cancel() - _ = rawFrames.Close() _ = pwFd.Close() _ = dbusConn.Close() return nil, fmt.Errorf("start encoder gst-launch: %w", err) } + _ = pipes.rawFrames.Close() + _ = pipes.encoderOutput.Close() + _ = pipes.encoderErrOut.Close() sourceWait, err := startGStreamerCommand(sourceCmd) if err != nil { cancel() - _ = rawFrames.Close() + pipes.close() _ = pwFd.Close() _ = dbusConn.Close() <-encoderWait return nil, fmt.Errorf("start capture gst-launch: %w", err) } + _ = pipes.sourceOutput.Close() + _ = pipes.sourceErrOut.Close() _ = pwFd.Close() // source child inherited it - go logStderr("GST-SOURCE", sourceStderr) - go logStderr("GST-ENCODER", encoderStderr) + go func() { + defer pipes.sourceStderr.Close() + logStderr("GST-SOURCE", pipes.sourceStderr) + }() + go func() { + defer pipes.encoderStderr.Close() + logStderr("GST-ENCODER", pipes.encoderStderr) + }() capture := &ScreenCapture{ cmd: cmd, sourceCmd: sourceCmd, - stdout: stdout, + stdout: pipes.stdout, cancel: cancel, pwNodeID: nodeID, dbusConn: dbusConn, waitCh: make(chan struct{}), } if timestampedOutput { - capture.frames = newRTPVideoAccessUnitReader(stdout, encoderParts.codec) + capture.frames = newRTPVideoAccessUnitReader(pipes.stdout, encoderParts.codec) } go func() { type processResult struct { diff --git a/internal/airplay/capture_test.go b/internal/airplay/capture_test.go index cfca892..0699013 100644 --- a/internal/airplay/capture_test.go +++ b/internal/airplay/capture_test.go @@ -14,6 +14,52 @@ import ( "github.com/godbus/dbus/v5" ) +func TestWaylandEncoderStartFailureClosesEveryPipeDescriptor(t *testing.T) { + before := openDescriptorCount(t) + for i := 0; i < 20; i++ { + source := exec.Command("true") + encoder := exec.Command("/definitely/not/a/doubletake-test-command") + pipes, err := openWaylandSplitPipes(source, encoder) + if err != nil { + t.Fatalf("openWaylandSplitPipes: %v", err) + } + if _, err := startWaylandEncoder(encoder, pipes); err == nil { + t.Fatal("startWaylandEncoder unexpectedly succeeded") + } + } + after := openDescriptorCount(t) + if after > before { + t.Fatalf("stderr setup failures leaked descriptors: before=%d after=%d", before, after) + } +} + +func TestOpenWaylandSplitPipesCloseReleasesEveryDescriptor(t *testing.T) { + before := openDescriptorCount(t) + source := exec.Command("true") + encoder := exec.Command("true") + pipes, err := openWaylandSplitPipes(source, encoder) + if err != nil { + t.Fatalf("openWaylandSplitPipes: %v", err) + } + pipes.close() + after := openDescriptorCount(t) + if after > before { + t.Fatalf("pipe close leaked descriptors: before=%d after=%d", before, after) + } +} + +func openDescriptorCount(t *testing.T) int { + t.Helper() + entries, err := os.ReadDir("/proc/self/fd") + if errors.Is(err, os.ErrNotExist) { + t.Skip("/proc/self/fd is unavailable") + } + if err != nil { + t.Fatalf("read /proc/self/fd: %v", err) + } + return len(entries) +} + func TestCapturePreparationCloseReleasesUnstartedResources(t *testing.T) { portalFD, peerFD, err := os.Pipe() if err != nil { From ccb0aa74bc24db1589d4f7be1bc19dd4efa0825b Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Sat, 29 Aug 2026 02:46:51 +0900 Subject: [PATCH 14/15] Finish atomic restore-token reset recovery Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/daemon/daemon.go | 32 +++++++ internal/daemon/reset_restore_token.go | 38 ++++++--- internal/daemon/reset_restore_token_test.go | 93 +++++++++++++++++++++ 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f19b580..3350404 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1354,6 +1354,10 @@ func (d *Daemon) getOrStartPreparedCaptureGroup(ctx context.Context, entry *acti if d.captureGroups == nil { d.captureGroups = make(map[videoCaptureKey]*videoCaptureGroup) } + if _, err := d.migrateRestoreTokenResetReservationLocked(entry, key); err != nil { + d.mu.Unlock() + return nil, 0, err + } group := d.captureGroups[key] var captureCtx context.Context var captureCancel context.CancelFunc @@ -1474,6 +1478,10 @@ func (d *Daemon) getOrStartCaptureGroup(entry *activeStream, restoreToken, devic if d.captureGroups == nil { d.captureGroups = make(map[videoCaptureKey]*videoCaptureGroup) } + if _, err := d.migrateRestoreTokenResetReservationLocked(entry, key); err != nil { + d.mu.Unlock() + return nil, err + } group := d.captureGroups[key] var captureCtx context.Context var captureCancel context.CancelFunc @@ -1565,6 +1573,30 @@ func (d *Daemon) getOrStartCaptureGroup(entry *activeStream, restoreToken, devic return newBC, nil } +// migrateRestoreTokenResetReservationLocked moves an owner-only reset claim +// when the receiver negotiates a different canvas after reconnecting. It never +// joins an occupied destination: reset replacement remains exclusive. +func (d *Daemon) migrateRestoreTokenResetReservationLocked(entry *activeStream, key videoCaptureKey) (*videoCaptureGroup, error) { + reservation := entry.captureGroup + if reservation == nil || reservation.resetReservedBy != entry || + reservation.broadcast != nil || reservation.capture != nil || reservation.cancel != nil { + return nil, nil + } + if reservation.key == key { + return reservation, nil + } + if d.captureGroups[reservation.key] != reservation { + return nil, context.Canceled + } + if d.captureGroups[key] != nil { + return nil, fmt.Errorf("%w: replacement canvas %dx%d is already active", errCaptureGroupResetReserved, key.maxWidth, key.maxHeight) + } + delete(d.captureGroups, reservation.key) + reservation.key = key + d.captureGroups[key] = reservation + return reservation, nil +} + // detachStreamLocked removes a single stream and transfers ownership of its // resources, plus an unused final capture group, to a cleanup plan. The caller // must unlock d.mu before running the plan. diff --git a/internal/daemon/reset_restore_token.go b/internal/daemon/reset_restore_token.go index a7e01c6..1ffd3cd 100644 --- a/internal/daemon/reset_restore_token.go +++ b/internal/daemon/reset_restore_token.go @@ -1,6 +1,10 @@ package daemon -import "context" +import ( + "context" + + "doubletake/internal/airplay" +) func (d *Daemon) handleResetRestoreToken(req Request) Response { d.mu.Lock() @@ -68,22 +72,18 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { group.resetReservedBy = nil } d.mu.Unlock() - if clearErr == nil { - _ = credentialReset.Rollback() - } + rollbackErr := rollbackRestoreTokenReset(credentialReset, clearErr) d.streamWorkers.Done() - return Response{OK: false, State: state, Error: "daemon is shutting down"} + return Response{OK: false, State: state, Error: resetFailure("daemon is shutting down", rollbackErr)} } if !reservationCurrent { if group.resetReservedBy == entry { group.resetReservedBy = nil } d.mu.Unlock() - if clearErr == nil { - _ = credentialReset.Rollback() - } + rollbackErr := rollbackRestoreTokenReset(credentialReset, clearErr) d.streamWorkers.Done() - return Response{OK: false, State: state, Error: "restore token reset was canceled for " + target} + return Response{OK: false, State: state, Error: resetFailure("restore token reset was canceled for "+target, rollbackErr)} } if clearErr != nil { group.resetReservedBy = nil @@ -132,12 +132,12 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { d.mu.Unlock() abandoned.run() cancel() - _ = credentialReset.Rollback() + rollbackErr := credentialReset.Rollback() d.streamWorkers.Done() if shuttingDown { - return Response{OK: false, State: state, Error: "daemon is shutting down"} + return Response{OK: false, State: state, Error: resetFailure("daemon is shutting down", rollbackErr)} } - return Response{OK: false, State: state, Error: "restore token reset was canceled for " + target} + return Response{OK: false, State: state, Error: resetFailure("restore token reset was canceled for "+target, rollbackErr)} } d.clearLastErrorForTargetLocked(target) state = d.overallStateLocked() @@ -151,6 +151,20 @@ func (d *Daemon) handleResetRestoreToken(req Request) Response { return Response{OK: true, State: state, Device: target, DeviceIP: target} } +func rollbackRestoreTokenReset(reset *airplay.RestoreTokenReset, clearErr error) error { + if clearErr != nil { + return nil + } + return reset.Rollback() +} + +func resetFailure(message string, rollbackErr error) string { + if rollbackErr == nil { + return message + } + return message + "; restore token rollback failed: " + rollbackErr.Error() +} + // restoreTokenResetReservationCurrentLocked reports whether the exact stream // and exclusive capture generation reserved before credential I/O are still // current. Must be called with d.mu held. diff --git a/internal/daemon/reset_restore_token_test.go b/internal/daemon/reset_restore_token_test.go index f2afdd1..2c9420f 100644 --- a/internal/daemon/reset_restore_token_test.go +++ b/internal/daemon/reset_restore_token_test.go @@ -404,6 +404,87 @@ func TestResetRestoreTokenConcurrentDisconnectReportsCancellationAndOverallState } } +func TestCanceledResetReportsRollbackPersistenceFailures(t *testing.T) { + for _, test := range []struct { + name string + lookupErrAt int + saveErrAt int + }{ + {name: "rollback lookup", lookupErrAt: 2}, + {name: "rollback save", saveErrAt: 2}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &controlledCredentialBackend{ + credentials: &airplay.SavedCredentials{RestoreToken: "restore-1"}, + lookupErrAt: test.lookupErrAt, + saveErrAt: test.saveErrAt, + } + d, entry, _, _ := newResetTestDaemon(t, backend) + cleanupStarted := make(chan struct{}) + cleanupRelease := make(chan struct{}) + originalCancel := entry.cancelFn + entry.cancelFn = func() { + close(cleanupStarted) + <-cleanupRelease + originalCancel() + } + defer d.Shutdown() + + resetResponse := make(chan Response, 1) + go func() { + resetResponse <- d.handleResetRestoreToken(Request{Cmd: "reset-restore-token", Target: resetTestTarget}) + }() + waitForResetSignal(t, cleanupStarted, "cleanup did not start") + if response := d.handleDisconnect(Request{Cmd: "disconnect", Target: resetTestTarget}); !response.OK { + t.Fatalf("disconnect response = %+v", response) + } + close(cleanupRelease) + + response := waitForResetResponse(t, resetResponse) + if response.OK || !strings.Contains(response.Error, "rollback failed") { + t.Fatalf("reset response = %+v, want explicit rollback failure", response) + } + }) + } +} + +func TestRestoreTokenResetReservationMigratesChangedCanvasExclusively(t *testing.T) { + d, entry, reservation, _ := newResetTestDaemon(t, &controlledCredentialBackend{}) + oldKey := reservation.key + newKey := normalizedVideoCaptureKey(1280, 720, airplay.VideoCodecH264) + reservation.broadcast = nil + reservation.resetReservedBy = entry + + d.mu.Lock() + migrated, err := d.migrateRestoreTokenResetReservationLocked(entry, newKey) + oldReleased := d.captureGroups[oldKey] == nil + newOwned := d.captureGroups[newKey] == reservation + d.mu.Unlock() + if err != nil || migrated != reservation || !oldReleased || !newOwned { + t.Fatalf("migration = %p, %v, oldReleased=%t newOwned=%t", migrated, err, oldReleased, newOwned) + } + + occupiedKey := normalizedVideoCaptureKey(3840, 2160, airplay.VideoCodecH264) + occupied := &videoCaptureGroup{key: occupiedKey} + d.mu.Lock() + d.captureGroups[occupiedKey] = occupied + _, err = d.migrateRestoreTokenResetReservationLocked(entry, occupiedKey) + stillOwned := d.captureGroups[newKey] == reservation && entry.captureGroup == reservation + d.mu.Unlock() + if err == nil || !errors.Is(err, errCaptureGroupResetReserved) || !stillOwned { + t.Fatalf("occupied migration = %v, stillOwned=%t", err, stillOwned) + } + + d.mu.Lock() + cleanup := d.detachStreamLocked(resetTestTarget) + reservationReleased := d.captureGroups[newKey] == nil + d.mu.Unlock() + cleanup.run() + if !reservationReleased { + t.Fatal("disconnect left migrated reservation behind") + } +} + func TestResetRestoreTokenClearsExclusiveTargetAndReconnectsActualPort(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -499,9 +580,17 @@ type controlledCredentialBackend struct { saveStarted chan struct{} saveRelease <-chan struct{} saveOnce sync.Once + lookupCalls int + saveCalls int + lookupErrAt int + saveErrAt int } func (b *controlledCredentialBackend) Lookup(string) (*airplay.SavedCredentials, error) { + b.lookupCalls++ + if b.lookupErrAt > 0 && b.lookupCalls == b.lookupErrAt { + return nil, errors.New("injected lookup failure") + } if b.lookupErr != nil { err := b.lookupErr b.lookupErr = nil @@ -521,6 +610,10 @@ func (b *controlledCredentialBackend) Save(_ string, credentials *airplay.SavedC if b.saveRelease != nil { <-b.saveRelease } + b.saveCalls++ + if b.saveErrAt > 0 && b.saveCalls == b.saveErrAt { + return errors.New("injected save failure") + } if b.saveErr != nil { return b.saveErr } From 70bc11b2985b1cc2e5a78c05e2254eb38487d22e Mon Sep 17 00:00:00 2001 From: Woonggi Min Date: Sat, 29 Aug 2026 03:01:02 +0900 Subject: [PATCH 15/15] Exercise raw relay negotiation Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- internal/airplay/capture_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/airplay/capture_test.go b/internal/airplay/capture_test.go index 0699013..b27218f 100644 --- a/internal/airplay/capture_test.go +++ b/internal/airplay/capture_test.go @@ -1,6 +1,7 @@ package airplay import ( + "bytes" "context" "errors" "os" @@ -14,6 +15,25 @@ import ( "github.com/godbus/dbus/v5" ) +func TestRawVideoRelayCapsNegotiateWithInstalledParser(t *testing.T) { + if _, err := exec.LookPath("gst-launch-1.0"); err != nil { + t.Skip("gst-launch-1.0 is unavailable") + } + const width, height = 4, 2 + args := []string{ + "--quiet", + "fdsrc", "fd=0", + "!", "video/x-raw,format=NV12,width=4,height=2,framerate=30/1", + "!", "rawvideoparse", "use-sink-caps=true", + "!", "fakesink", + } + cmd := exec.Command("gst-launch-1.0", args...) + cmd.Stdin = bytes.NewReader(make([]byte, width*height*3/2)) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("raw relay caps failed real GStreamer negotiation: %v\n%s", err, output) + } +} + func TestWaylandEncoderStartFailureClosesEveryPipeDescriptor(t *testing.T) { before := openDescriptorCount(t) for i := 0; i < 20; i++ {