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 <noreply@anthropic.com>
This commit is contained in:
Daniel Gräfe 2026-01-23 10:18:42 +01:00
parent b8b6ab102f
commit 6bceea7928
2 changed files with 67 additions and 17 deletions

View File

@ -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 {

View File

@ -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)