Skip to content

Commit 90a5ea0

Browse files
committed
Fix multiple active streams
1 parent c3de88a commit 90a5ea0

8 files changed

Lines changed: 816 additions & 118 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ mode. In the Plasma applet, an on-screen PIN uses a visible four-digit field,
157157
while a configured password uses an unrestricted masked field. Doubletake does
158158
not request or claim that a PIN is visible in password mode. The daemon exposes
159159
the distinction while retaining `doubletake-ctl pin <PIN-or-password>` for
160-
command compatibility.
160+
command compatibility when exactly one receiver is waiting. With concurrent
161+
prompts, submit each value with `doubletake-ctl connect TARGET PIN-or-password`.
161162

162163
One thing that makes this confusing to diagnose: **"Require Password" is a
163164
fixed password you set, not a rotating onscreen code.** Nothing appears on the
@@ -306,7 +307,9 @@ doubletake-ctl unmute [target]
306307
- `disconnect <target>` stops only that receiver.
307308
- `mute`/`unmute` can operate globally or per target.
308309
- `pin` retains its historical command name, but submits whichever credential
309-
the daemon requests: an on-screen PIN or a configured password.
310+
the daemon requests: an on-screen PIN or a configured password. It is
311+
targetless and therefore requires exactly one waiting receiver; use
312+
`connect <target> <PIN-or-password>` when multiple receivers are waiting.
310313

311314
## Disclaimer
312315

internal/daemon/daemon.go

Lines changed: 139 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,13 @@ type Daemon struct {
159159
credStore *airplay.CredentialStore
160160

161161
// Multi-stream state
162-
streams map[string]*activeStream // keyed by target IP
163-
broadcast *airplay.BroadcastCapture // shared video fan-out; nil when no streams active
164-
capture *airplay.ScreenCapture // underlying screen capture
165-
captureCancel context.CancelFunc // cancellation for shared capture context
166-
167-
// Credential-waiting state (at most one device prompts at a time).
168-
pendingTarget string
162+
streams map[string]*activeStream // keyed by target IP
163+
broadcast *airplay.BroadcastCapture // shared video fan-out; nil when no streams active
164+
capture *airplay.ScreenCapture // underlying screen capture
165+
captureCancel context.CancelFunc // cancellation for starting/running shared capture
166+
captureStartMu sync.Mutex // serializes creation of the shared capture
167+
lastError string // most recent asynchronous stream failure
168+
lastErrorTarget string // target associated with lastError; empty for capture-wide errors
169169

170170
discoverCancel context.CancelFunc
171171
listener net.Listener
@@ -368,15 +368,12 @@ func (d *Daemon) handleRequest(req Request) Response {
368368
// overallState returns the aggregate daemon state based on active streams.
369369
// Must be called with d.mu held.
370370
func (d *Daemon) overallStateLocked() State {
371-
if d.pendingTarget != "" {
372-
if pending := d.streams[d.pendingTarget]; pending != nil && pending.state == StatePINRequired {
373-
return StatePINRequired
374-
}
375-
}
376371
hasStreaming := false
377372
hasConnecting := false
378373
for _, s := range d.streams {
379374
switch s.state {
375+
case StatePINRequired:
376+
return StatePINRequired
380377
case StateStreaming:
381378
hasStreaming = true
382379
case StateConnecting:
@@ -399,6 +396,9 @@ func (d *Daemon) handleStatus() Response {
399396
}
400397

401398
func (d *Daemon) statusResponseLocked(ok bool, errMsg string) Response {
399+
if errMsg == "" {
400+
errMsg = d.lastError
401+
}
402402
streams := make([]StreamInfo, 0, len(d.streams))
403403
for _, s := range d.streams {
404404
streams = append(streams, StreamInfo{
@@ -431,10 +431,16 @@ func (d *Daemon) statusResponseLocked(ok bool, errMsg string) Response {
431431
break
432432
}
433433
}
434-
if pending := d.streams[d.pendingTarget]; pending != nil && pending.state == StatePINRequired {
435-
device = pending.device
436-
deviceIP = pending.deviceIP
437-
credentialKind = waitingCredentialKind(pending)
434+
// The top-level fields can describe only one prompt. Select the first pending
435+
// stream from the already sorted list for deterministic legacy behavior; all
436+
// prompts remain available in Streams for multi-target control clients.
437+
for _, stream := range streams {
438+
if stream.State == StatePINRequired {
439+
device = stream.Device
440+
deviceIP = stream.DeviceIP
441+
credentialKind = stream.CredentialKind
442+
break
443+
}
438444
}
439445

440446
return Response{
@@ -487,38 +493,44 @@ func (d *Daemon) handleDevices() Response {
487493
func (d *Daemon) handleConnect(req Request) Response {
488494
d.mu.Lock()
489495

490-
// If we're waiting for a credential, resume the existing connection rather
491-
// than creating a new client and losing its pending authentication session.
492-
if d.pendingTarget != "" && req.Pin != "" {
493-
target := d.pendingTarget
494-
if req.Target != "" && req.Target != target {
495-
d.mu.Unlock()
496-
return Response{OK: false, State: StatePINRequired, Error: "a different device is waiting for a credential"}
497-
}
498-
entry, ok := d.streams[target]
499-
if !ok || entry.credentialCh == nil {
500-
d.pendingTarget = ""
501-
state := d.overallStateLocked()
502-
d.mu.Unlock()
503-
return Response{OK: false, State: state, Error: "pending credential session is no longer available"}
496+
// Resume a pending authentication session without replacing its AirPlay
497+
// client. A target is required only when more than one receiver is waiting;
498+
// targetless submissions remain compatible with older control clients.
499+
if req.Pin != "" {
500+
target := req.Target
501+
if target == "" {
502+
pending := d.pendingCredentialTargetsLocked()
503+
switch len(pending) {
504+
case 0:
505+
state := d.overallStateLocked()
506+
d.mu.Unlock()
507+
return Response{OK: false, State: state, Error: "no device is waiting for a credential"}
508+
case 1:
509+
target = pending[0]
510+
default:
511+
state := d.overallStateLocked()
512+
d.mu.Unlock()
513+
return Response{OK: false, State: state, Error: "multiple devices are waiting for credentials; specify a target"}
514+
}
504515
}
505-
if entry.state != StatePINRequired {
516+
517+
if entry, ok := d.streams[target]; ok {
518+
if entry.state != StatePINRequired || entry.credentialCh == nil {
519+
state := d.overallStateLocked()
520+
d.mu.Unlock()
521+
return Response{OK: false, State: state, Error: "credential prompt is not ready for " + target}
522+
}
523+
entry.state = StateConnecting
524+
entry.credentialKind = ""
525+
d.clearLastErrorForTargetLocked(target)
526+
// credentialCh is buffered and each pending session can be claimed only once.
527+
entry.credentialCh <- req.Pin
506528
state := d.overallStateLocked()
507529
d.mu.Unlock()
508-
return Response{OK: false, State: state, Error: "credential prompt is not ready"}
530+
return Response{OK: true, State: state, Device: target, DeviceIP: target}
509531
}
510-
d.pendingTarget = ""
511-
entry.state = StateConnecting
512-
entry.credentialKind = ""
513-
// credentialCh is buffered and each pending session can be claimed only once.
514-
entry.credentialCh <- req.Pin
515-
d.mu.Unlock()
516-
return Response{OK: true, State: StateConnecting, Device: target}
517-
}
518-
if req.Pin != "" && req.Target == "" {
519-
state := d.overallStateLocked()
520-
d.mu.Unlock()
521-
return Response{OK: false, State: state, Error: "no device is waiting for a credential"}
532+
// No existing entry means this is a new targeted connection with a
533+
// credential supplied up front; continue into normal connection setup.
522534
}
523535

524536
// Reject a duplicate connection to the same target.
@@ -564,6 +576,7 @@ func (d *Daemon) handleConnect(req Request) Response {
564576
cancelFn: cancel,
565577
credentialCh: make(chan string, 1),
566578
}
579+
d.clearLastErrorForTargetLocked(target)
567580
d.streams[target] = entry
568581
d.mu.Unlock()
569582

@@ -574,6 +587,30 @@ func (d *Daemon) handleConnect(req Request) Response {
574587
return Response{OK: true, State: d.overallStateLocked(), Device: target}
575588
}
576589

590+
// pendingCredentialTargetsLocked returns pending target IPs in stable order.
591+
// Must be called with d.mu held.
592+
func (d *Daemon) pendingCredentialTargetsLocked() []string {
593+
targets := make([]string, 0)
594+
for target, stream := range d.streams {
595+
if stream.state == StatePINRequired && stream.credentialCh != nil {
596+
targets = append(targets, target)
597+
}
598+
}
599+
sort.Strings(targets)
600+
return targets
601+
}
602+
603+
// clearLastErrorForTargetLocked acknowledges an error only when retrying the
604+
// receiver that produced it. Starting another target must not hide a concurrent
605+
// connection failure before a control client has a chance to report it.
606+
// Must be called with d.mu held.
607+
func (d *Daemon) clearLastErrorForTargetLocked(target string) {
608+
if d.lastErrorTarget == "" || d.lastErrorTarget == target {
609+
d.lastError = ""
610+
d.lastErrorTarget = ""
611+
}
612+
}
613+
577614
// pickFreeDeviceLocked returns the first discovered device not already in d.streams.
578615
// Must be called with d.mu held.
579616
func (d *Daemon) pickFreeDeviceLocked(preferredPort int) (string, int) {
@@ -593,14 +630,23 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
593630
// removeStream cleans up this stream's entry and tears down the shared broadcast
594631
// if no other streams remain.
595632
removeStream := func(msg string) {
596-
if msg != "" {
597-
log.Printf("[daemon] %s", msg)
633+
failure := msg
634+
if failure != "" {
635+
failure = fmt.Sprintf("%s: %s", target, failure)
598636
}
599637
d.mu.Lock()
600638
defer d.mu.Unlock()
601-
if d.streams[target] == entry {
602-
d.removeStreamLocked(target)
639+
// A user-requested disconnect removes the entry before closing its client.
640+
// Ignore the resulting read/handshake error from that obsolete goroutine.
641+
if d.streams[target] != entry {
642+
return
643+
}
644+
if failure != "" {
645+
log.Printf("[daemon] %s", failure)
646+
d.lastError = failure
647+
d.lastErrorTarget = target
603648
}
649+
d.removeStreamLocked(target)
604650
}
605651

606652
// A receiver-configured password and an on-screen pairing PIN travel through
@@ -695,11 +741,6 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
695741
d.mu.Unlock()
696742
return "", context.Canceled
697743
}
698-
if d.pendingTarget != "" && d.pendingTarget != target {
699-
d.mu.Unlock()
700-
return "", fmt.Errorf("another device is already waiting for a credential")
701-
}
702-
d.pendingTarget = target
703744
entry.credentialKind = kind
704745
d.mu.Unlock()
705746

@@ -708,9 +749,6 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
708749
if kind == CredentialKindPIN {
709750
if err := client.StartPINDisplay(); err != nil {
710751
d.mu.Lock()
711-
if d.pendingTarget == target {
712-
d.pendingTarget = ""
713-
}
714752
if d.streams[target] == entry {
715753
entry.credentialKind = ""
716754
}
@@ -720,7 +758,7 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
720758
}
721759

722760
d.mu.Lock()
723-
if d.streams[target] != entry || d.pendingTarget != target {
761+
if d.streams[target] != entry {
724762
d.mu.Unlock()
725763
return "", context.Canceled
726764
}
@@ -733,9 +771,6 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
733771
return value, nil
734772
case <-ctx.Done():
735773
d.mu.Lock()
736-
if d.pendingTarget == target {
737-
d.pendingTarget = ""
738-
}
739774
if d.streams[target] == entry {
740775
entry.credentialKind = ""
741776
}
@@ -937,6 +972,11 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
937972
// maxW/maxH clamp the encoded size for the receiver that starts the capture;
938973
// sinks that join later share it.
939974
func (d *Daemon) getOrStartBroadcastLocked(restoreToken, deviceID string, maxW, maxH int) (*airplay.BroadcastSink, error) {
975+
// Two targets may finish authentication together. Serialize the nil check and
976+
// capture startup so Wayland opens only one portal and X11 starts one encoder.
977+
d.captureStartMu.Lock()
978+
defer d.captureStartMu.Unlock()
979+
940980
d.mu.Lock()
941981
bc := d.broadcast
942982
d.mu.Unlock()
@@ -947,6 +987,19 @@ func (d *Daemon) getOrStartBroadcastLocked(restoreToken, deviceID string, maxW,
947987
return sink, nil
948988
}
949989

990+
// Publish the startup cancellation before entering the display portal or
991+
// launching GStreamer. A concurrent disconnect-all can then interrupt a
992+
// capture that has not returned from StartCapture yet.
993+
captureCtx, captureCancel := context.WithCancel(context.Background())
994+
d.mu.Lock()
995+
if len(d.streams) == 0 {
996+
d.mu.Unlock()
997+
captureCancel()
998+
return nil, context.Canceled
999+
}
1000+
d.captureCancel = captureCancel
1001+
d.mu.Unlock()
1002+
9501003
// Start a fresh screen capture.
9511004
capCfg := airplay.CaptureConfig{
9521005
FPS: d.cfg.FPS,
@@ -967,40 +1020,58 @@ func (d *Daemon) getOrStartBroadcastLocked(restoreToken, deviceID string, maxW,
9671020
capture *airplay.ScreenCapture
9681021
err error
9691022
)
970-
captureCtx, captureCancel := context.WithCancel(context.Background())
9711023
if d.cfg.TestMode {
9721024
capture, err = airplay.StartTestCapture(captureCtx, capCfg)
9731025
} else {
9741026
capture, err = airplay.StartCapture(captureCtx, capCfg)
9751027
}
9761028
if err != nil {
9771029
captureCancel()
1030+
d.mu.Lock()
1031+
// captureStartMu prevents another startup from replacing this cancel
1032+
// function before this attempt has finished.
1033+
if d.broadcast == nil && d.capture == nil {
1034+
d.captureCancel = nil
1035+
}
1036+
d.mu.Unlock()
9781037
return nil, err
9791038
}
9801039

9811040
newBC := airplay.NewBroadcastCapture(capture)
9821041
sink := newBC.AddSink()
9831042

9841043
d.mu.Lock()
985-
// Double-check: another goroutine might have started capture concurrently.
986-
if d.broadcast != nil {
1044+
if len(d.streams) == 0 || d.captureCancel == nil {
9871045
d.mu.Unlock()
988-
// Discard the one we just started and use the existing one.
9891046
captureCancel()
9901047
capture.Stop()
991-
return d.broadcast.AddSink(), nil
1048+
return nil, context.Canceled
9921049
}
9931050
d.broadcast = newBC
9941051
d.capture = capture
9951052
d.captureCancel = captureCancel
9961053
d.mu.Unlock()
9971054

9981055
go func() {
999-
if runErr := newBC.Run(); runErr != nil && runErr.Error() != "EOF" {
1056+
runErr := newBC.Run()
1057+
unexpected := runErr != nil && runErr.Error() != "EOF"
1058+
d.mu.Lock()
1059+
// A stopped capture can finish after a replacement connection has already
1060+
// been queued. Its cleanup must not tear down that newer generation.
1061+
if d.broadcast != newBC {
1062+
d.mu.Unlock()
1063+
return
1064+
}
1065+
if unexpected {
10001066
log.Printf("[daemon] broadcast capture error: %v", runErr)
10011067
}
1002-
// When the capture ends, stop all active streams.
1003-
d.mu.Lock()
1068+
// When the active capture ends, stop all streams consuming it.
1069+
// Shutdown also closes the capture and can produce a benign read error.
1070+
// Only retain failures that ended streams which were still active.
1071+
if unexpected && len(d.streams) > 0 {
1072+
d.lastError = "shared capture failed: " + runErr.Error()
1073+
d.lastErrorTarget = ""
1074+
}
10041075
d.stopAllLocked()
10051076
d.mu.Unlock()
10061077
}()
@@ -1015,9 +1086,6 @@ func (d *Daemon) removeStreamLocked(target string) {
10151086
if !ok {
10161087
return
10171088
}
1018-
if d.pendingTarget == target {
1019-
d.pendingTarget = ""
1020-
}
10211089
if entry.cancelFn != nil {
10221090
entry.cancelFn()
10231091
}
@@ -1127,7 +1195,6 @@ func (d *Daemon) handleSetMute(req Request, muted bool) Response {
11271195
// stopAllLocked stops all active streams and tears down the capture.
11281196
// Must be called with d.mu held.
11291197
func (d *Daemon) stopAllLocked() {
1130-
d.pendingTarget = ""
11311198
for target, entry := range d.streams {
11321199
if entry.cancelFn != nil {
11331200
entry.cancelFn()

0 commit comments

Comments
 (0)