From 6bceea7928f8db4426e59bc54060d09fec306027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gr=C3=A4fe?= Date: Fri, 23 Jan 2026 10:18:42 +0100 Subject: [PATCH] Lazy clipboard init and backoff when SPICE not connected When the SPICE clipboard channel is not available (e.g., running headless or without clipboard sharing), the vdagent would previously retry every 1 second, initializing the clipboard library each time. This caused unnecessary CPU usage. Changes: - Defer clipboard.Init() until first successful SPICE message - Add ErrSPICENotConnected error type to distinguish "never connected" from "connection lost" - Use 1-minute backoff when SPICE is not available, vs 1-second for transient failures Co-Authored-By: Claude Opus 4.5 --- internal/command/root.go | 30 +++++++++++------ internal/spice/vdagent/vdagent.go | 54 +++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/internal/command/root.go b/internal/command/root.go index f5960a3..44c73bd 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -31,7 +31,13 @@ var runAgent bool var debug bool -const componentFailedTimeout = time.Second +const ( + // componentFailedTimeout is the default retry delay for transient failures + componentFailedTimeout = time.Second + // spiceNotConnectedTimeout is the retry delay when SPICE clipboard channel + // is not available (e.g., running headless or without clipboard sharing) + spiceNotConnectedTimeout = time.Minute +) func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ @@ -109,12 +115,10 @@ func run(cmd *cobra.Command, args []string) error { if runVdagent { group.Go(func() error { for { - if err := runVdagentOnce(ctx); err != nil { - return err - } + backoff := runVdagentOnce(ctx) select { - case <-time.After(componentFailedTimeout): + case <-time.After(backoff): continue case <-ctx.Done(): return ctx.Err() @@ -152,26 +156,32 @@ func run(cmd *cobra.Command, args []string) error { return group.Wait() } -func runVdagentOnce(ctx context.Context) error { +func runVdagentOnce(ctx context.Context) time.Duration { zap.S().Infof("initializing vdagent...") vdAgent, err := vdagent.New() if err != nil { zap.S().Errorf("failed to initialize vdagent: %v", err) - return nil + return componentFailedTimeout } defer vdAgent.Close() zap.S().Infof("running vdagent...") if err := vdAgent.Run(ctx); err != nil { - zap.S().Errorf("failed to run vdagent: %v", err) + // Check if SPICE clipboard channel is not available + var spiceErr *vdagent.ErrSPICENotConnected + if errors.As(err, &spiceErr) { + zap.S().Infof("SPICE clipboard channel not available, will retry in %v", spiceNotConnectedTimeout) + return spiceNotConnectedTimeout + } - return nil + zap.S().Errorf("failed to run vdagent: %v", err) + return componentFailedTimeout } - return nil + return componentFailedTimeout } func runRPCOnce(ctx context.Context) error { diff --git a/internal/spice/vdagent/vdagent.go b/internal/spice/vdagent/vdagent.go index fd345b4..84a393b 100644 --- a/internal/spice/vdagent/vdagent.go +++ b/internal/spice/vdagent/vdagent.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "github.com/cirruslabs/tart-guest-agent/internal/spice/vd" "github.com/cirruslabs/tart-guest-agent/internal/spice/vdi" "go.uber.org/zap" @@ -14,10 +15,26 @@ import ( const serialPortPath = "/dev/tty.com.redhat.spice.0" +// ErrSPICENotConnected is returned when the SPICE clipboard channel +// is not available. This typically happens when running headless or +// when clipboard sharing is not enabled on the host. +type ErrSPICENotConnected struct { + Err error +} + +func (e *ErrSPICENotConnected) Error() string { + return fmt.Sprintf("SPICE clipboard channel not connected: %v", e.Err) +} + +func (e *ErrSPICENotConnected) Unwrap() error { + return e.Err +} + type VDAgent struct { - serialPort *os.File - vdi *vdi.VDI - lastClipboardState []byte + serialPort *os.File + vdi *vdi.VDI + lastClipboardState []byte + clipboardInitialized bool } func New() (*VDAgent, error) { @@ -26,9 +43,8 @@ func New() (*VDAgent, error) { return nil, err } - if err := clipboard.Init(); err != nil { - return nil, err - } + // Note: clipboard.Init() is deferred until we confirm SPICE is connected. + // This avoids expensive initialization when clipboard sharing is unavailable. return &VDAgent{ serialPort: sp, @@ -37,7 +53,14 @@ func New() (*VDAgent, error) { } func (agent *VDAgent) Run(ctx context.Context) error { - clipboardCh := clipboard.Watch(ctx, clipboard.FmtText) + // clipboardCh is nil until clipboard is initialized. + // A nil channel blocks forever on receive, which is fine for the select. + var clipboardCh <-chan []byte + + // Create a child context for clipboard.Watch() so we can cancel it + // when Run() exits, preventing goroutine leaks on retry. + clipboardCtx, clipboardCancel := context.WithCancel(ctx) + defer clipboardCancel() for { // Check for cancellation and clipboard changes @@ -63,9 +86,26 @@ func (agent *VDAgent) Run(ctx context.Context) error { continue } + // If we haven't successfully initialized clipboard yet, + // this means SPICE was never connected - use special error + // to signal longer backoff. + if !agent.clipboardInitialized { + return &ErrSPICENotConnected{Err: err} + } + return err } + // First successful read from SPICE - now initialize clipboard + if !agent.clipboardInitialized { + if err := clipboard.Init(); err != nil { + return fmt.Errorf("failed to initialize clipboard: %w", err) + } + clipboardCh = clipboard.Watch(clipboardCtx, clipboard.FmtText) + agent.clipboardInitialized = true + zap.S().Debug("SPICE connected, clipboard initialized") + } + switch vdiAgentMessage.Type { case vd.VD_AGENT_ANNOUNCE_CAPABILITIES: vdAgentAnnounceCapabilities, err := vd.ReadVDAgentAnnounceCapabilities(vdiAgentMessage.Data)