Address CodeRabbit feedback on VNC server agent matching and session lifecycle

This commit is contained in:
Viktor Liu
2026-05-21 12:01:45 +02:00
parent 353639b57c
commit 3b39ee0a79
6 changed files with 64 additions and 44 deletions
+15 -19
View File
@@ -10,7 +10,6 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"sync" "sync"
"syscall" "syscall"
@@ -253,15 +252,19 @@ func killAllVNCAgents() {
} }
// vncAgentPIDs returns the pids of vnc-agent subprocesses spawned from // vncAgentPIDs returns the pids of vnc-agent subprocesses spawned from
// this binary. Matches on (argv[0] basename == our own basename) AND // this binary. Matches exactly on argv[0] == our own executable path
// argv contains the "vnc-agent" subcommand. Skips pid 0 and 1 defensively. // AND argv[1] == "vnc-agent" so unrelated processes that happen to have
// the same name elsewhere in argv are not targeted. Skips pid 0 and 1
// defensively.
func vncAgentPIDs() ([]int, error) { func vncAgentPIDs() ([]int, error) {
procs, err := unix.SysctlKinfoProcSlice("kern.proc.all") procs, err := unix.SysctlKinfoProcSlice("kern.proc.all")
if err != nil { if err != nil {
return nil, fmt.Errorf("sysctl kern.proc.all: %w", err) return nil, fmt.Errorf("sysctl kern.proc.all: %w", err)
} }
ownExe, _ := os.Executable() ownExe, err := os.Executable()
ownBase := filepath.Base(ownExe) if err != nil {
return nil, fmt.Errorf("resolve own executable: %w", err)
}
var out []int var out []int
for i := range procs { for i := range procs {
pid := int(procs[i].Proc.P_pid) pid := int(procs[i].Proc.P_pid)
@@ -269,7 +272,7 @@ func vncAgentPIDs() ([]int, error) {
continue continue
} }
argv, err := procArgv(pid) argv, err := procArgv(pid)
if err != nil || !argvIsVNCAgent(argv, ownBase) { if err != nil || !argvIsVNCAgent(argv, ownExe) {
continue continue
} }
out = append(out, pid) out = append(out, pid)
@@ -313,19 +316,12 @@ func procArgv(pid int) ([]string, error) {
} }
// argvIsVNCAgent reports whether argv belongs to a vnc-agent subprocess // argvIsVNCAgent reports whether argv belongs to a vnc-agent subprocess
// spawned from our binary. Requires argv[0]'s basename to match ownBase // spawned from our binary. Requires argv[0] to match ownExe exactly and
// and the "vnc-agent" subcommand to appear among the positional args. // argv[1] to be the vnc-agent subcommand. Matches the spawn shape in
func argvIsVNCAgent(argv []string, ownBase string) bool { // spawnAgentForUser and rejects anything else.
if len(argv) < 2 || ownBase == "" { func argvIsVNCAgent(argv []string, ownExe string) bool {
if len(argv) < 2 || ownExe == "" {
return false return false
} }
if filepath.Base(argv[0]) != ownBase { return argv[0] == ownExe && argv[1] == vncAgentSubcommand
return false
}
for _, a := range argv[1:] {
if a == vncAgentSubcommand {
return true
}
}
return false
} }
@@ -1,4 +1,4 @@
//go:build unix && !darwin && !ios && !android //go:build linux
package server package server
+6
View File
@@ -482,6 +482,7 @@ var (
procGlobalAlloc = kernel32.NewProc("GlobalAlloc") procGlobalAlloc = kernel32.NewProc("GlobalAlloc")
procGlobalLock = kernel32.NewProc("GlobalLock") procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock") procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procGlobalFree = kernel32.NewProc("GlobalFree")
) )
const ( const (
@@ -522,6 +523,7 @@ func (w *WindowsInputInjector) doSetClipboard(text string) {
ptr, _, _ := procGlobalLock.Call(hMem) ptr, _, _ := procGlobalLock.Call(hMem)
if ptr == 0 { if ptr == 0 {
log.Tracef("GlobalLock for clipboard: lock returned nil") log.Tracef("GlobalLock for clipboard: lock returned nil")
_, _, _ = procGlobalFree.Call(hMem)
return return
} }
copy(unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), len(utf16)), utf16) copy(unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), len(utf16)), utf16)
@@ -530,6 +532,7 @@ func (w *WindowsInputInjector) doSetClipboard(text string) {
r, _, lerr := procOpenClipboard.Call(0) r, _, lerr := procOpenClipboard.Call(0)
if r == 0 { if r == 0 {
log.Tracef("OpenClipboard: %v", lerr) log.Tracef("OpenClipboard: %v", lerr)
_, _, _ = procGlobalFree.Call(hMem)
return return
} }
defer logCleanupCall("CloseClipboard", procCloseClipboard) defer logCleanupCall("CloseClipboard", procCloseClipboard)
@@ -538,6 +541,9 @@ func (w *WindowsInputInjector) doSetClipboard(text string) {
r, _, lerr = procSetClipboardData.Call(cfUnicodeText, hMem) r, _, lerr = procSetClipboardData.Call(cfUnicodeText, hMem)
if r == 0 { if r == 0 {
log.Tracef("SetClipboardData: %v", lerr) log.Tracef("SetClipboardData: %v", lerr)
// Ownership only transfers to the OS on success; on failure we
// still own hMem and must free it.
_, _, _ = procGlobalFree.Call(hMem)
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ package server
// //
// Linux KEY_* codes. Only the ones we reference, since the full // Linux KEY_* codes. Only the ones we reference, since the full
// linux/input-event-codes.h list isn't useful here. Naming mirrors the // linux/input-event-codes.h list isn't useful here. Naming mirrors the
// existing constants in input_uinput_unix.go (mixed case, no underscores). // existing constants in input_uinput_linux.go (mixed case, no underscores).
const ( const (
keyEsc = 1 keyEsc = 1
key1 = 2 key1 = 2
+27 -22
View File
@@ -418,11 +418,15 @@ func (s *Server) Stop() error {
s.cancel = nil s.cancel = nil
} }
// Close active client connections before tearing down capturers and // Close the listener first so the accept loop exits and cannot
// listeners. The per-session serve goroutines unblock from their Read // register any further connections in acceptedConns. Then close every
// loop with an error and run their deferred conn.Close, which surfaces // already-accepted connection so per-session serve goroutines unblock
// a clean disconnect on the client side instead of leaving the // and run their deferred conn.Close.
// connection hanging until the OS reclaims it on process exit. var listenerErr error
if s.listener != nil {
listenerErr = s.listener.Close()
s.listener = nil
}
s.closeActiveSessions() s.closeActiveSessions()
if s.vmgr != nil { if s.vmgr != nil {
@@ -437,12 +441,8 @@ func (s *Server) Stop() error {
c.Close() c.Close()
} }
if s.listener != nil { if listenerErr != nil {
err := s.listener.Close() return fmt.Errorf("close VNC listener: %w", listenerErr)
s.listener = nil
if err != nil {
return fmt.Errorf("close VNC listener: %w", err)
}
} }
s.log.Info("stopped") s.log.Info("stopped")
@@ -894,7 +894,8 @@ func (s *Server) acquireSessionResources(conn net.Conn, header *connectionHeader
case ModeSession: case ModeSession:
return s.acquireVirtualSession(conn, header, connLog) return s.acquireVirtualSession(conn, header, connLog)
default: default:
return s.acquireAttachSession(), s.injector, attachSessionCleanup, true capturer, cleanup := s.acquireAttachSession()
return capturer, s.injector, cleanup, true
} }
} }
@@ -921,17 +922,21 @@ func (s *Server) acquireVirtualSession(conn net.Conn, header *connectionHeader,
return vs.Capturer(), vs.Injector(), vs.ClientDisconnect, true return vs.Capturer(), vs.Injector(), vs.ClientDisconnect, true
} }
func (s *Server) acquireAttachSession() ScreenCapturer { // acquireAttachSession bumps the shared capturer's per-session refcount
if cc, ok := s.capturer.(interface{ ClientConnect() }); ok { // (if it implements the optional ClientConnect/ClientDisconnect pair) and
cc.ClientConnect() // returns a cleanup func that releases it. X11Poller and the Windows
// capturer rely on the disconnect path to drop SHM/DXGI resources when no
// client is active.
func (s *Server) acquireAttachSession() (ScreenCapturer, func()) {
type connectDisconnect interface {
ClientConnect()
ClientDisconnect()
} }
return s.capturer if cc, ok := s.capturer.(connectDisconnect); ok {
} cc.ClientConnect()
return s.capturer, cc.ClientDisconnect
// attachSessionCleanup is the no-op cleanup used by attach mode. Returned as a }
// named func rather than an inline closure so the empty body is unambiguous. return s.capturer, func() {}
func attachSessionCleanup() {
// Attach mode keeps the shared capturer; nothing to release per session.
} }
// modeString returns a human-readable session mode name. // modeString returns a human-readable session mode name.
+14 -1
View File
@@ -176,10 +176,19 @@ func (vs *VirtualSession) ClientDisconnect() {
// idleExpired is called by the idle timer. It stops the session and // idleExpired is called by the idle timer. It stops the session and
// notifies the session manager via onIdle so it removes us from the map. // notifies the session manager via onIdle so it removes us from the map.
// Bails out early if a client reconnected before the timer callback won
// the race (Stop() doesn't cancel an already-firing AfterFunc, so the
// state check has to happen here under vs.mu).
func (vs *VirtualSession) idleExpired() { func (vs *VirtualSession) idleExpired() {
vs.mu.Lock()
if vs.stopped || vs.clients > 0 {
vs.mu.Unlock()
return
}
vs.mu.Unlock()
vs.log.Info("idle timeout reached, destroying virtual session") vs.log.Info("idle timeout reached, destroying virtual session")
vs.Stop() vs.Stop()
// onIdle acquires sessionManager.mu; safe because Stop() has released vs.mu.
if vs.onIdle != nil { if vs.onIdle != nil {
vs.onIdle() vs.onIdle()
} }
@@ -231,6 +240,10 @@ func (vs *VirtualSession) Stop() {
if vs.injector != nil { if vs.injector != nil {
vs.injector.Close() vs.injector.Close()
} }
if vs.poller != nil {
vs.poller.Close()
vs.poller = nil
}
vs.stopDesktop() vs.stopDesktop()
vs.stopXvfb() vs.stopXvfb()