vibetunnel/linux/pkg/session/session.go
Helmut Januschka b90bfd9f46
Add Go implementation of VibeTunnel server (#16)
* Add Linux implementation of VibeTunnel

This commit introduces a complete Linux port of VibeTunnel, providing feature parity with the macOS version. The implementation includes:

- Full Go-based server with identical REST API and WebSocket endpoints
- Terminal session management using PTY (pseudo-terminal) handling
- Asciinema recording format for session playback
- Compatible CLI interface matching the macOS `vt` command
- Support for all VibeTunnel features: password protection, network modes, ngrok integration
- Comprehensive build system with Makefile supporting various installation methods
- Systemd service integration for running as a system daemon

The Linux version maintains 100% compatibility with the existing web UI and can be used as a drop-in replacement for the macOS app on Linux systems.

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

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

* Add comprehensive ngrok integration to Linux VibeTunnel

Implements full ngrok tunnel support for the Go/Linux version to match
the macOS Swift implementation, enabling secure public access to local
VibeTunnel instances.

- **ngrok Service**: Complete lifecycle management with status tracking
- **HTTP API**: RESTful endpoints matching macOS version
- **CLI Support**: Command-line ngrok flags and integration
- **Auto-forwarding**: Built-in HTTP request forwarding to local server

- `POST /api/ngrok/start` - Start tunnel with auth token
- `POST /api/ngrok/stop` - Stop active tunnel
- `GET /api/ngrok/status` - Get current tunnel status

- Uses `golang.ngrok.com/ngrok` SDK for native Go integration
- Thread-safe service with mutex protection
- Comprehensive error handling and logging
- Real-time status updates (disconnected/connecting/connected/error)
- Proper context cancellation for graceful shutdown

```bash
vibetunnel --serve --ngrok --ngrok-token "your_token"
vibetunnel --serve --port 4030 --ngrok --ngrok-token "your_token"
```

- Added golang.ngrok.com/ngrok v1.13.0
- Updated web packages (security fixes for puppeteer)

Maintains full API compatibility with macOS VibeTunnel for seamless
cross-platform operation and consistent web frontend integration.

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

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

* up

* Fix SSE streaming performance with byte-based approach

Addresses @badlogic's review feedback to prevent performance issues
with line-based file reading in processNewContent().

## Changes Made

### Performance Fix
- **Byte-based seeking**: Replace line counting with file position tracking
- **Efficient reads**: Only read new content since last position using file.Seek()
- **Memory optimization**: Avoid reading entire file on each update
- **Incomplete line handling**: Properly handle partial lines at file end

### Technical Details
- Changed lastLineCount *int → seenBytes *int64
- Use file.Seek(seenBytes, 0) to jump to last read position
- Read only new content with currentSize - seenBytes
- Handle incomplete lines by adjusting seek position
- Maintains same functionality with better performance

### Benefits
- **Scalability**: No longer reads entire file for each update
- **Performance**: O(new_content) instead of O(total_content)
- **Memory**: Constant memory usage regardless of file size
- **Reliability**: Handles concurrent writes and partial lines correctly

This prevents the SSE streaming from exploding in our faces as @badlogic
warned, especially for long-running sessions with large output files.

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

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

* Optimize streaming performance to reduce 1+ second delays

Implements multiple optimizations to address user-reported 1+ second delay
between typing and stream rendering:

## PTY Reading Optimizations
- **Reduced sleep times**: 100ms → 1ms for EOF checks
- **Faster polling**: 10ms → 1ms for zero-byte reads
- **FIFO optimization**: 1s → 100ms for stdin EOF polling

## UTF-8 Buffering Improvements
- **Timeout-based flushing**: 5ms timer for incomplete UTF-8 sequences
- **Real-time streaming**: Don't wait for complete sequences in interactive mode
- **Smart buffering**: Balance correctness with responsiveness

## File I/O Optimizations
- **Immediate sync**: Call file.Sync() after each write for instant fsnotify
- **Reduced SSE timeout**: 1s → 100ms for session alive checks
- **Better responsiveness**: Ensure file changes trigger immediately

## Technical Changes
- Added StreamWriter.scheduleFlush() with 5ms timeout
- Enhanced writeEvent() with conditional file syncing
- Optimized PTY read/write loop timing
- Improved SSE streaming frequency

These changes target the main bottlenecks identified in the
PTY → file → fsnotify → SSE → browser pipeline.

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

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

* Fix critical stdin polling delay causing 1+ second input lag

- Reduced FIFO EOF polling from 100ms to 1ms
- Reduced EAGAIN polling from 1ms to 100µs
- Added immediate continue after successful writes
- This eliminates the major input delay bottleneck

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

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

* Fix critical performance issues causing resource leaks and CPU burns

Performance optimizations based on code review feedback:

1. **Fix SSE goroutine leaks**:
   - Added client disconnect detection to SSE streams
   - Propagate write errors to detect when clients close connections
   - Prevents memory leaks from abandoned streaming goroutines

2. **Fix PTY busy-loop CPU burn**:
   - Increased sleep from 1ms to 10ms in idle scenarios
   - Reduces CPU wake-ups from 1000/s to 100/s (10x improvement)
   - Significantly reduces CPU usage when PTY is idle

3. **Multi-stream disconnect detection**:
   - Added error checking to multi-stream write operations
   - Prevents goroutine leaks in multi-session streaming

These fixes address the "thing of the things" - performance\!

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

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

* Standardize session creation API response format to match Rust server

Changes:
- Updated Go server session creation response to include success/message/error fields
- Now returns: {"success": true, "message": "Session created successfully", "error": null, "sessionId": "..."}
- Maintains backward compatibility with existing sessionId field
- Go server already supported both input formats (cmdline/command, cwd/workingDir)

This achieves protocol compatibility between Go and Rust implementations.

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

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

* Fix delete endpoint to return 200 OK with JSON response

- Changed handleKillSession to return 200 OK instead of 204 No Content
- Added JSON response with success/message fields for consistency
- Fixes benchmark tool compatibility expecting 200 response

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

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

* Update Go server API to match Rust format exactly

- Use 'command' array instead of 'cmdline'
- Use 'workingDir' instead of 'cwd'
- Remove compatibility shims for cleaner API
- Better error messages matching Rust server

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

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

* Major performance optimizations for Go server

- Remove 100ms artificial delay in session creation (-100ms per session)
- Optimize PTY I/O handling with reduced polling intervals
- Implement persistent stdin pipes to avoid repeated open/close
- Batch file sync operations to reduce I/O overhead (5ms batching)
- Remove blocking status updates from API handlers
- Increase SSE session check interval from 100ms to 1s

Target: Match Rust performance (60ms avg latency, 16+ ops/sec)

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

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

* Fix O_NONBLOCK compilation issue

* Add comprehensive TLS/HTTPS support with Caddy integration

Features:
- Optional TLS support via CLI flags (defaults to HTTP like Rust)
- Self-signed certificate generation for localhost development
- Let's Encrypt automatic certificate management for domains
- Custom certificate support for production environments
- HTTP to HTTPS redirect capability
- Maintains 100% backward compatibility with Rust version

Usage examples:
- Default HTTP: ./vibetunnel --serve (same as Rust)
- HTTPS with self-signed: ./vibetunnel --serve --tls
- HTTPS with domain: ./vibetunnel --serve --tls --tls-domain example.com
- HTTPS with custom certs: ./vibetunnel --serve --tls --tls-cert cert.pem --tls-key key.pem

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

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

* Fix terminal sizing issues and implement dynamic resize support

Backend changes:
- Add handleResizeSession API endpoint for dynamic terminal resizing
- Implement Session.Resize() and PTY.Resize() methods with proper validation
- Add session registry in Manager to track running sessions with PTY access
- Fix stdin error handling to prevent session crashes on EAGAIN errors
- Write resize events to asciinema stream for frontend synchronization
- Update default terminal dimensions from 80x24 to 120x30

Frontend changes:
- Add width/height parameters to SessionCreateData interface
- Calculate appropriate terminal dimensions when creating sessions
- Implement automatic resize API calls when terminal dimensions change
- Add terminal-resize event dispatch for backend synchronization
- Ensure resize events bubble properly for session management

Fixes nvim being stuck at 80x24 by implementing proper terminal
dimension management and dynamic resizing capabilities.

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

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

* Add client-side resize caching and Hack Nerd Font support

- Implement resize request caching to prevent redundant API calls
- Add debouncing to terminal resize events (250ms delay)
- Replace ResizeObserver with window.resize events only to eliminate pixel-level jitter
- Add Hack Nerd Font Mono as primary terminal font with Fira Code fallback
- Update session creation to use conservative 120x30 defaults
- Fix terminal dimension calculation in normal mode

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

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

* Add comprehensive XTerm color and rendering enhancements

- Complete 256-color palette support with CSS variables (0-255)
- Enhanced XTerm configuration with proper terminal options
- True xterm-compatible 16-color theme
- Text attribute support: bold, italic, underline, dim, strikethrough, inverse, invisible
- Cursor blinking with CSS animation
- Font rendering optimizations (disabled ligatures, antialiasing)
- Terminal-specific CSS styling for better rendering
- Mac option key as meta, alt-click cursor movement
- Selection colors and inactive selection support

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-18 23:32:35 +02:00

359 lines
No EOL
7.7 KiB
Go

package session
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
)
type Status string
const (
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusExited Status = "exited"
)
type Config struct {
Name string
Cmdline []string
Cwd string
Env []string
Width int
Height int
}
type Info struct {
ID string `json:"id"`
Name string `json:"name"`
Cmdline string `json:"cmdline"`
Cwd string `json:"cwd"`
Pid int `json:"pid,omitempty"`
Status string `json:"status"`
ExitCode *int `json:"exit_code,omitempty"`
StartedAt time.Time `json:"started_at"`
Term string `json:"term"`
Width int `json:"width"`
Height int `json:"height"`
Env map[string]string `json:"env,omitempty"`
Args []string `json:"-"` // Internal use only
}
type Session struct {
ID string
controlPath string
info *Info
pty *PTY
stdinPipe *os.File
stdinMutex sync.Mutex
}
func newSession(controlPath string, config Config) (*Session, error) {
id := uuid.New().String()
sessionPath := filepath.Join(controlPath, id)
log.Printf("[DEBUG] Creating new session %s with config: Name=%s, Cmdline=%v, Cwd=%s",
id[:8], config.Name, config.Cmdline, config.Cwd)
if err := os.MkdirAll(sessionPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create session directory: %w", err)
}
if config.Name == "" {
config.Name = id[:8]
}
// Set default command if empty
if len(config.Cmdline) == 0 {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/bash"
}
config.Cmdline = []string{shell}
log.Printf("[DEBUG] Session %s: Set default command to %v", id[:8], config.Cmdline)
}
// Set default working directory if empty
if config.Cwd == "" {
cwd, err := os.Getwd()
if err != nil {
config.Cwd = os.Getenv("HOME")
if config.Cwd == "" {
config.Cwd = "/"
}
} else {
config.Cwd = cwd
}
log.Printf("[DEBUG] Session %s: Set default working directory to %s", id[:8], config.Cwd)
}
term := os.Getenv("TERM")
if term == "" {
term = "xterm-256color"
}
// Set default terminal dimensions if not provided
width := config.Width
if width <= 0 {
width = 120 // Better default for modern terminals
}
height := config.Height
if height <= 0 {
height = 30 // Better default for modern terminals
}
info := &Info{
ID: id,
Name: config.Name,
Cmdline: strings.Join(config.Cmdline, " "),
Cwd: config.Cwd,
Status: string(StatusStarting),
StartedAt: time.Now(),
Term: term,
Width: width,
Height: height,
Args: config.Cmdline,
}
if err := info.Save(sessionPath); err != nil {
os.RemoveAll(sessionPath)
return nil, fmt.Errorf("failed to save session info: %w", err)
}
return &Session{
ID: id,
controlPath: controlPath,
info: info,
}, nil
}
func loadSession(controlPath, id string) (*Session, error) {
sessionPath := filepath.Join(controlPath, id)
info, err := LoadInfo(sessionPath)
if err != nil {
return nil, err
}
session := &Session{
ID: id,
controlPath: controlPath,
info: info,
}
// If session is running, we need to reconnect to the PTY for operations like resize
// For now, we'll handle this by checking if we need PTY access in individual methods
return session, nil
}
func (s *Session) Path() string {
return filepath.Join(s.controlPath, s.ID)
}
func (s *Session) StreamOutPath() string {
return filepath.Join(s.Path(), "stream-out")
}
func (s *Session) StdinPath() string {
return filepath.Join(s.Path(), "stdin")
}
func (s *Session) NotificationPath() string {
return filepath.Join(s.Path(), "notification-stream")
}
func (s *Session) Start() error {
pty, err := NewPTY(s)
if err != nil {
return fmt.Errorf("failed to create PTY: %w", err)
}
s.pty = pty
s.info.Status = string(StatusRunning)
s.info.Pid = pty.Pid()
if err := s.info.Save(s.Path()); err != nil {
pty.Close()
return fmt.Errorf("failed to update session info: %w", err)
}
go func() {
if err := s.pty.Run(); err != nil {
log.Printf("[DEBUG] Session %s: PTY.Run() exited with error: %v", s.ID[:8], err)
} else {
log.Printf("[DEBUG] Session %s: PTY.Run() exited normally", s.ID[:8])
}
}()
// Process status will be checked on first access - no artificial delay needed
log.Printf("[DEBUG] Session %s: Started successfully", s.ID[:8])
return nil
}
func (s *Session) Attach() error {
if s.pty == nil {
return fmt.Errorf("session not started")
}
return s.pty.Attach()
}
func (s *Session) SendKey(key string) error {
return s.sendInput([]byte(key))
}
func (s *Session) SendText(text string) error {
return s.sendInput([]byte(text))
}
func (s *Session) sendInput(data []byte) error {
s.stdinMutex.Lock()
defer s.stdinMutex.Unlock()
// Open pipe if not already open
if s.stdinPipe == nil {
stdinPath := s.StdinPath()
pipe, err := os.OpenFile(stdinPath, os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("failed to open stdin pipe: %w", err)
}
s.stdinPipe = pipe
}
_, err := s.stdinPipe.Write(data)
if err != nil {
// If write fails, close and reset the pipe for next attempt
s.stdinPipe.Close()
s.stdinPipe = nil
return fmt.Errorf("failed to write to stdin pipe: %w", err)
}
return nil
}
func (s *Session) Signal(sig string) error {
if s.info.Pid == 0 {
return fmt.Errorf("no process to signal")
}
proc, err := os.FindProcess(s.info.Pid)
if err != nil {
return err
}
switch sig {
case "SIGTERM":
return proc.Signal(os.Interrupt)
case "SIGKILL":
return proc.Kill()
default:
return fmt.Errorf("unsupported signal: %s", sig)
}
}
func (s *Session) Stop() error {
return s.Signal("SIGTERM")
}
func (s *Session) Kill() error {
err := s.Signal("SIGKILL")
s.cleanup()
return err
}
func (s *Session) cleanup() {
s.stdinMutex.Lock()
defer s.stdinMutex.Unlock()
if s.stdinPipe != nil {
s.stdinPipe.Close()
s.stdinPipe = nil
}
}
func (s *Session) Resize(width, height int) error {
if s.pty == nil {
return fmt.Errorf("session not started")
}
// Check if session is still alive
if s.info.Status == string(StatusExited) {
return fmt.Errorf("cannot resize exited session")
}
// Validate dimensions
if width <= 0 || height <= 0 {
return fmt.Errorf("invalid dimensions: width=%d, height=%d", width, height)
}
// Update session info
s.info.Width = width
s.info.Height = height
// Save updated session info
if err := s.info.Save(s.Path()); err != nil {
log.Printf("[ERROR] Failed to save session info after resize: %v", err)
}
// Resize the PTY
return s.pty.Resize(width, height)
}
func (s *Session) IsAlive() bool {
if s.info.Pid == 0 {
return false
}
proc, err := os.FindProcess(s.info.Pid)
if err != nil {
return false
}
err = proc.Signal(syscall.Signal(0))
return err == nil
}
func (s *Session) UpdateStatus() error {
if s.info.Status == string(StatusExited) {
return nil
}
if !s.IsAlive() {
s.info.Status = string(StatusExited)
exitCode := 0
s.info.ExitCode = &exitCode
return s.info.Save(s.Path())
}
return nil
}
func (i *Info) Save(sessionPath string) error {
data, err := json.MarshalIndent(i, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(sessionPath, "session.json"), data, 0644)
}
func LoadInfo(sessionPath string) (*Info, error) {
data, err := os.ReadFile(filepath.Join(sessionPath, "session.json"))
if err != nil {
return nil, err
}
var info Info
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}