vibetunnel/linux/pkg/session/select.go
Helmut Januschka a66620be52
fix: Go server CPU optimization & resize control flag (#32)
* fix: optimize Go server CPU usage from 500%+ to efficient levels

Major performance improvements to resolve excessive CPU consumption:

**Critical Fixes:**
- Remove WebSocket busy loop that caused continuous CPU spinning
- Fix microsecond-level polling (100μs → 10ms) reducing 100x operations
- Replace ps subprocess calls with efficient kill(pid,0) syscalls
- Increase timer intervals (1s → 30s) for session status checks

**Optimizations:**
- Control FIFO polling: 100ms → 1s intervals
- Select timeout: 100ms → 1s to reduce unnecessary wakeups
- Smart status caching: skip checks for already-exited sessions
- Remove unused imports (os/exec, strconv)

**Impact:**
- Eliminates tight loops causing 10,000+ operations per second
- Reduces subprocess overhead from frequent ps command executions
- Changes from polling-based to efficient event-driven architecture
- Expected CPU usage reduction from 500%+ to levels comparable with Node.js version

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: prevent WebSocket channel close panic with sync.Once

- Add sync.Once to prevent double-closing of done channel
- Update handleTextMessage signature to accept close function
- Use closeOnceFunc for safe channel closure across goroutines

Fixes panic: 'close of closed channel' in WebSocket handler

* feat: add --do-not-allow-column-set flag to disable resizing for spawned sessions

**New Flag:**
- `--do-not-allow-column-set` (default: true) - Disables terminal resizing for spawned shells
- Only affects sessions created with `spawn_terminal=true`
- Detached sessions (command-line, API without spawn) always allow resizing

**Implementation:**
- Add `IsSpawned` field to session.Config and session.Info structs
- Track whether session was spawned in terminal vs detached
- Server checks flag + spawn status before allowing resize operations
- Returns descriptive error for blocked resize attempts

**Behavior:**
- Spawned sessions: Resize blocked when flag enabled (default)
- Detached sessions: Always allow resizing regardless of flag
- Existing sessions preserve their resize capabilities

**API Response for blocked resize:**
```json
{
  "success": false,
  "message": "Resizing is disabled for spawned sessions",
  "error": "resize_disabled_for_spawned_sessions"
}
```

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: extend --do-not-allow-column-set flag to block ALL session resizing

**Breaking Change:** Flag now affects both spawned AND detached sessions

**Changes:**
- Remove `sess.IsSpawned()` check in resize handler
- Block resizing for ALL sessions when flag is enabled (default: true)
- Update flag description: "Disable terminal resizing for all sessions (spawned and detached)"
- Update error message: "Terminal resizing is disabled by server configuration"
- Update error code: "resize_disabled_by_server"

**New Behavior:**
- `--do-not-allow-column-set=true` (default): NO resizing for any session type
- `--do-not-allow-column-set=false`: Allow resizing for all session types
- Applies uniformly to both spawned terminal windows and detached CLI sessions

**API Response for blocked resize:**
```json
{
  "success": false,
  "message": "Terminal resizing is disabled by server configuration",
  "error": "resize_disabled_by_server"
}
```

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-20 11:37:54 +02:00

172 lines
3.8 KiB
Go

//go:build darwin || linux
// +build darwin linux
package session
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
// selectRead performs a select() operation on multiple file descriptors
func selectRead(fds []int, timeout time.Duration) ([]int, error) {
if len(fds) == 0 {
return nil, fmt.Errorf("no file descriptors to select on")
}
// Find the highest FD number
maxFd := 0
for _, fd := range fds {
if fd > maxFd {
maxFd = fd
}
}
// Create FD set
var readSet syscall.FdSet
for _, fd := range fds {
fdSetAdd(&readSet, fd)
}
// Convert timeout to timeval
tv := syscall.NsecToTimeval(timeout.Nanoseconds())
// Perform select
err := syscall.Select(maxFd+1, &readSet, nil, nil, &tv)
if err != nil {
if err == syscall.EINTR || err == syscall.EAGAIN {
return []int{}, nil // Interrupted or would block
}
return nil, err
}
// Check which FDs are ready
var ready []int
for _, fd := range fds {
if fdIsSet(&readSet, fd) {
ready = append(ready, fd)
}
}
return ready, nil
}
// fdSetAdd adds a file descriptor to an FdSet
func fdSetAdd(set *syscall.FdSet, fd int) {
set.Bits[fd/64] |= 1 << uint(fd%64)
}
// fdIsSet checks if a file descriptor is set in an FdSet
func fdIsSet(set *syscall.FdSet, fd int) bool {
return set.Bits[fd/64]&(1<<uint(fd%64)) != 0
}
// pollWithSelect polls multiple file descriptors using select
func (p *PTY) pollWithSelect() error {
// Buffer for reading
buf := make([]byte, 32*1024)
// Get file descriptors
ptyFd := int(p.pty.Fd())
stdinFd := int(p.stdinPipe.Fd())
// Open control FIFO in non-blocking mode
controlPath := filepath.Join(p.session.Path(), "control")
controlFile, err := os.OpenFile(controlPath, os.O_RDONLY|syscall.O_NONBLOCK, 0)
var controlFd int = -1
if err == nil {
controlFd = int(controlFile.Fd())
defer controlFile.Close()
} else {
log.Printf("[WARN] Failed to open control FIFO: %v", err)
}
for {
// Build FD list
fds := []int{ptyFd, stdinFd}
if controlFd >= 0 {
fds = append(fds, controlFd)
}
// Wait for activity with 1s timeout to reduce CPU usage
ready, err := selectRead(fds, 1*time.Second)
if err != nil {
log.Printf("[ERROR] select error: %v", err)
return err
}
// Check if process has exited
if p.cmd.ProcessState != nil {
return nil
}
// Process ready file descriptors
for _, fd := range ready {
switch fd {
case ptyFd:
// Read from PTY
n, err := syscall.Read(ptyFd, buf)
if err != nil {
if err == syscall.EIO {
// PTY closed
return nil
}
log.Printf("[ERROR] PTY read error: %v", err)
return err
}
if n > 0 {
// Write to output
if err := p.streamWriter.WriteOutput(buf[:n]); err != nil {
log.Printf("[ERROR] Failed to write to stream: %v", err)
}
}
case stdinFd:
// Read from stdin FIFO
n, err := syscall.Read(stdinFd, buf)
if err != nil && err != syscall.EAGAIN {
log.Printf("[ERROR] stdin read error: %v", err)
continue
}
if n > 0 {
// Write to PTY
if _, err := p.pty.Write(buf[:n]); err != nil {
log.Printf("[ERROR] Failed to write to PTY: %v", err)
}
}
case controlFd:
// Read from control FIFO
n, err := syscall.Read(controlFd, buf)
if err != nil && err != syscall.EAGAIN {
log.Printf("[ERROR] control read error: %v", err)
continue
}
if n > 0 {
// Parse control commands
cmdStr := string(buf[:n])
for _, line := range strings.Split(cmdStr, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var cmd ControlCommand
if err := json.Unmarshal([]byte(line), &cmd); err != nil {
log.Printf("[ERROR] Failed to parse control command: %v", err)
continue
}
p.session.handleControlCommand(&cmd)
}
}
}
}
}
}