@@ -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.
370370func (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
401398func (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 {
487493func (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.
579616func (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.
939974func (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.
11291197func (d * Daemon ) stopAllLocked () {
1130- d .pendingTarget = ""
11311198 for target , entry := range d .streams {
11321199 if entry .cancelFn != nil {
11331200 entry .cancelFn ()
0 commit comments