mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-28 09:55:53 +00:00
* 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>
172 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|