Skip to content

Commit 6d3ca69

Browse files
Backlog/v12 agent connection (#2535)
* fix[agent-manager](grpc): prevent command system freeze when a panel disconnects mid-command * fix[installer](nginx): make agent-manager gRPC streams persistent in the front-end proxy
1 parent 760c9af commit 6d3ca69

3 files changed

Lines changed: 283 additions & 110 deletions

File tree

agent-manager/agent/agent_imp.go

Lines changed: 141 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
374399
func (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

Comments
 (0)