@@ -264,8 +264,8 @@ func (s *AgentService) ListAgents(ctx context.Context, req *ListRequest) (*ListA
264264 if req .GetTenantId () != "" {
265265 filter = append (filter , utils.Filter {
266266 Field : "tenant_id" ,
267- Op : utils .Is ,
268- Value :sanitizeTenant (req .GetTenantId ()),
267+ Op : utils .Is ,
268+ Value : sanitizeTenant (req .GetTenantId ()),
269269 })
270270 }
271271
@@ -353,24 +353,49 @@ func (s *AgentService) AgentStream(stream AgentService_AgentStreamServer) error
353353 switch msg := in .StreamMessage .(type ) {
354354 case * BidirectionalStream_Result :
355355 catcher .Info ("Received command result from agent" , map [string ]any {"agent_id" : msg .Result .AgentId , "result" : msg .Result .Result , "process" : "agent-manager" })
356- cmdID := msg .Result .GetCmdId ()
357-
358- s .CommandResultChannelM .Lock ()
359- if resultChan , ok := s .CommandResultChannel [cmdID ]; ok {
360- resultChan <- & CommandResult {
361- AgentId : msg .Result .AgentId ,
362- Result : msg .Result .Result ,
363- CmdId : cmdID ,
364- ExecutedAt : msg .Result .ExecutedAt ,
365- }
366- } else if OnCommandResultHook == nil || ! OnCommandResultHook (msg .Result ) {
367- catcher .Error ("failed to find result channel for CmdID" , nil , map [string ]any {"cmdID" : cmdID , "process" : "agent-manager" })
356+ if ! s .tryDeliverResult (msg .Result ) &&
357+ (OnCommandResultHook == nil || ! OnCommandResultHook (msg .Result )) {
358+ catcher .Error ("failed to find result channel for CmdID" , nil , map [string ]any {"cmdID" : msg .Result .GetCmdId (), "process" : "agent-manager" })
368359 }
369- s .CommandResultChannelM .Unlock ()
370360 }
371361 }
372362}
373363
364+ // tryDeliverResult hands an agent command result to the panel waiting on it.
365+ // It must never block: the AgentStream goroutine is the single consumer of the
366+ // agent's socket, and a stuck send here freezes every result for that agent
367+ // (and the global CommandResultChannelM for the whole service). A missing
368+ // slot, or a slot whose panel already went away (result unclaimed), returns
369+ // false without blocking.
370+ func (s * AgentService ) tryDeliverResult (result * CommandResult ) bool {
371+ cmdID := result .GetCmdId ()
372+ s .CommandResultChannelM .Lock ()
373+ defer s .CommandResultChannelM .Unlock ()
374+ resultChan , ok := s .CommandResultChannel [cmdID ]
375+ if ! ok {
376+ return false
377+ }
378+ select {
379+ case resultChan <- result :
380+ return true
381+ default :
382+ // Slot is full: the panel that owned it left without claiming the
383+ // result. Drop the stale slot so the stream keeps flowing.
384+ delete (s .CommandResultChannel , cmdID )
385+ catcher .Error ("dropping agent result: panel disconnected before claiming it" , nil , map [string ]any {"cmdID" : cmdID , "process" : "agent-manager" })
386+ return false
387+ }
388+ }
389+
390+ // reclaimResultSlot removes the panel command slot, releasing any AgentStream
391+ // goroutine blocked on delivering a result for it. Called via defer on every
392+ // exit path of handlePanelCommand.
393+ func (s * AgentService ) reclaimResultSlot (cmdID string ) {
394+ s .CommandResultChannelM .Lock ()
395+ delete (s .CommandResultChannel , cmdID )
396+ s .CommandResultChannelM .Unlock ()
397+ }
398+
374399func (s * AgentService ) ProcessCommand (stream PanelService_ProcessCommandServer ) error {
375400 for {
376401 cmd , err := stream .Recv ()
@@ -380,103 +405,117 @@ func (s *AgentService) ProcessCommand(stream PanelService_ProcessCommandServer)
380405 if err != nil {
381406 return status .Error (codes .Internal , fmt .Sprintf ("failed to receive message: %v" , err ))
382407 }
383- streamId , err := strconv .Atoi (cmd .AgentId )
384- if err != nil {
385- return status .Error (codes .InvalidArgument , "invalid agent ID" )
386- }
387- agentStream , ok := s .AgentStreamMap [uint (streamId )]
388- if ! ok {
389- return status .Errorf (codes .NotFound , "agent not found or is disconnected" )
408+ if err := s .handlePanelCommand (stream , cmd ); err != nil {
409+ return err
390410 }
411+ }
412+ }
391413
392- target := & models. Agent {}
393- if dErr := s . DBConnection . GetFirst ( target , "id = ?" , streamId ); dErr == nil && target . NoRemoteControl {
394- return status . Errorf ( codes . PermissionDenied ,
395- "agent %d was installed with remote control disabled; it can only be changed on the machine itself" , streamId )
396- }
397- if cmd . GetOriginId () == "" {
398- return status . Errorf ( codes . NotFound , "agent origin ID not provided" )
399- }
400- if cmd . GetOriginType () == "" {
401- return status . Errorf ( codes . NotFound , "agent origin TYPE not provided" )
402- }
403- if cmd . GetReason () == "" {
404- return status .Errorf (codes .NotFound , "agent command reason not provided " )
405- }
414+ // handlePanelCommand forwards one panel command to the agent stream and waits
415+ // for the result (max 5 minutes). The result slot is created before the send
416+ // and reclaimed via defer on EVERY exit — success, timeout, and panel
417+ // disconnect — so a result that arrives after the panel is gone can never
418+ // block the AgentStream goroutine.
419+ func ( s * AgentService ) handlePanelCommand ( stream PanelService_ProcessCommandServer , cmd * UtmCommand ) error {
420+ streamId , err := strconv . Atoi ( cmd . AgentId )
421+ if err != nil {
422+ return status . Error ( codes . InvalidArgument , "invalid agent ID" )
423+ }
424+ agentStream , ok := s . AgentStreamMap [ uint ( streamId )]
425+ if ! ok {
426+ return status .Errorf (codes .NotFound , "agent not found or is disconnected " )
427+ }
406428
407- cmdID := cmd .GetCmdId ()
408- if cmdID == "" {
409- cmdID = uuid .New ().String ()
410- }
429+ target := & models.Agent {}
430+ if dErr := s .DBConnection .GetFirst (target , "id = ?" , streamId ); dErr == nil && target .NoRemoteControl {
431+ return status .Errorf (codes .PermissionDenied ,
432+ "agent %d was installed with remote control disabled; it can only be changed on the machine itself" , streamId )
433+ }
434+ if cmd .GetOriginId () == "" {
435+ return status .Errorf (codes .NotFound , "agent origin ID not provided" )
436+ }
437+ if cmd .GetOriginType () == "" {
438+ return status .Errorf (codes .NotFound , "agent origin TYPE not provided" )
439+ }
440+ if cmd .GetReason () == "" {
441+ return status .Errorf (codes .NotFound , "agent command reason not provided" )
442+ }
411443
412- s .CommandResultChannelM .Lock ()
413- s .CommandResultChannel [cmdID ] = make (chan * CommandResult )
414- s .CommandResultChannelM .Unlock ()
444+ cmdID := cmd .GetCmdId ()
445+ if cmdID == "" {
446+ cmdID = uuid .New ().String ()
447+ }
415448
416- histCommand := createHistoryCommand (cmd , cmdID , uint (streamId ))
417- err = s .DBConnection .Create (& histCommand )
418- if err != nil {
419- catcher .Error ("unable to create a new command history" , err , map [string ]any {"process" : "agent-manager" })
420- }
449+ s .CommandResultChannelM .Lock ()
450+ // Buffered by 1: a result can always be absorbed even when the panel is no
451+ // longer waiting, so delivery in AgentStream never blocks on it.
452+ s .CommandResultChannel [cmdID ] = make (chan * CommandResult , 1 )
453+ s .CommandResultChannelM .Unlock ()
454+ defer s .reclaimResultSlot (cmdID )
421455
422- var lock sync.Locker
423- if LockStreamHook != nil {
424- lock = LockStreamHook (uint (streamId ))
456+ histCommand := createHistoryCommand (cmd , cmdID , uint (streamId ))
457+ if cErr := s .DBConnection .Create (& histCommand ); cErr != nil {
458+ catcher .Error ("unable to create a new command history" , cErr , map [string ]any {"process" : "agent-manager" })
459+ }
460+
461+ var lock sync.Locker
462+ if LockStreamHook != nil {
463+ lock = LockStreamHook (uint (streamId ))
464+ }
465+ func () {
466+ if lock != nil {
467+ lock .Lock ()
468+ defer lock .Unlock ()
425469 }
426- func () {
427- if lock != nil {
428- lock .Lock ()
429- defer lock .Unlock ()
430- }
431- err = agentStream .Send (& BidirectionalStream {
432- StreamMessage : & BidirectionalStream_Command {
433- Command : & UtmCommand {
434- AgentId : cmd .AgentId ,
435- Command : replaceSecretValues (cmd .Command ),
436- CmdId : cmdID ,
437- Shell : cmd .Shell ,
438- },
470+ err = agentStream .Send (& BidirectionalStream {
471+ StreamMessage : & BidirectionalStream_Command {
472+ Command : & UtmCommand {
473+ AgentId : cmd .AgentId ,
474+ Command : replaceSecretValues (cmd .Command ),
475+ CmdId : cmdID ,
476+ Shell : cmd .Shell ,
439477 },
440- })
441- }()
442- if err != nil {
443- return status .Errorf (codes .Internal , "failed to send command to agent: %v" , err )
444- }
445-
446- select {
447- case result := <- s .CommandResultChannel [cmdID ]:
448- err = s .DBConnection .Upsert (
449- & models.AgentCommand {},
450- "agent_id = ? AND cmd_id = ?" ,
451- map [string ]interface {}{"command_status" : models .Executed , "result" : result .Result },
452- cmd .AgentId , cmdID ,
453- )
454- if err != nil {
455- catcher .Error ("failed to update command status" , err , map [string ]any {"process" : "agent-manager" })
456- }
457-
458- err = stream .Send (result )
459- if err != nil {
460- return err
461- }
462- case <- time .After (5 * time .Minute ):
463- s .CommandResultChannelM .Lock ()
464- delete (s .CommandResultChannel , cmdID )
465- s .CommandResultChannelM .Unlock ()
466-
467- _ = s .DBConnection .Upsert (
468- & models.AgentCommand {},
469- "agent_id = ? AND cmd_id = ?" ,
470- map [string ]interface {}{"command_status" : models .Error , "result" : "command timed out after 5 minutes" },
471- cmd .AgentId , cmdID ,
472- )
473-
474- return status .Errorf (codes .DeadlineExceeded , "agent did not respond within 5 minutes" )
478+ },
479+ })
480+ }()
481+ if err != nil {
482+ return status .Errorf (codes .Internal , "failed to send command to agent: %v" , err )
483+ }
484+
485+ select {
486+ case result := <- s .CommandResultChannel [cmdID ]:
487+ if uErr := s .DBConnection .Upsert (
488+ & models.AgentCommand {},
489+ "agent_id = ? AND cmd_id = ?" ,
490+ map [string ]interface {}{"command_status" : models .Executed , "result" : result .Result },
491+ cmd .AgentId , cmdID ,
492+ ); uErr != nil {
493+ catcher .Error ("failed to update command status" , uErr , map [string ]any {"process" : "agent-manager" })
475494 }
476495
477- s .CommandResultChannelM .Lock ()
478- delete (s .CommandResultChannel , cmdID )
479- s .CommandResultChannelM .Unlock ()
496+ return stream .Send (result )
497+ case <- time .After (5 * time .Minute ):
498+ _ = s .DBConnection .Upsert (
499+ & models.AgentCommand {},
500+ "agent_id = ? AND cmd_id = ?" ,
501+ map [string ]interface {}{"command_status" : models .Error , "result" : "command timed out after 5 minutes" },
502+ cmd .AgentId , cmdID ,
503+ )
504+
505+ return status .Errorf (codes .DeadlineExceeded , "agent did not respond within 5 minutes" )
506+ case <- stream .Context ().Done ():
507+ // The panel went away (viewer disconnected, backend request canceled).
508+ // Mark the history row, then exit without waiting: the deferred slot
509+ // reclamation keeps the AgentStream goroutine unblocked when the agent
510+ // finally responds.
511+ _ = s .DBConnection .Upsert (
512+ & models.AgentCommand {},
513+ "agent_id = ? AND cmd_id = ?" ,
514+ map [string ]interface {}{"command_status" : models .Error , "result" : "panel disconnected before the agent responded" },
515+ cmd .AgentId , cmdID ,
516+ )
517+
518+ return stream .Context ().Err ()
480519 }
481520}
482521
0 commit comments