vibetunnel/linux/pkg/session/session.go
2025-06-20 20:16:46 +02:00

743 lines
19 KiB
Go

package session
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
"github.com/shirou/gopsutil/v3/process"
)
// GenerateID generates a new unique session ID
func GenerateID() string {
return uuid.New().String()
}
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
IsSpawned bool // Whether this session was spawned in a terminal
}
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
IsSpawned bool `json:"is_spawned"` // Whether session was spawned in terminal
}
type Session struct {
ID string
controlPath string
info *Info
pty *PTY
stdinPipe *os.File
stdinMutex sync.Mutex
mu sync.RWMutex
manager *Manager // Reference to manager for accessing global settings
}
func newSession(controlPath string, config Config, manager *Manager) (*Session, error) {
id := uuid.New().String()
return newSessionWithID(controlPath, id, config, manager)
}
func newSessionWithID(controlPath string, id string, config Config, manager *Manager) (*Session, error) {
sessionPath := filepath.Join(controlPath, id)
// Only log in debug mode
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
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}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
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
}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
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,
IsSpawned: config.IsSpawned,
}
if err := info.Save(sessionPath); err != nil {
if err := os.RemoveAll(sessionPath); err != nil {
log.Printf("[WARN] Failed to remove session path %s: %v", sessionPath, err)
}
return nil, fmt.Errorf("failed to save session info: %w", err)
}
return &Session{
ID: id,
controlPath: controlPath,
info: info,
manager: manager,
}, nil
}
func loadSession(controlPath, id string, manager *Manager) (*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,
manager: manager,
}
// Validate that essential session files exist
streamPath := filepath.Join(sessionPath, "stream-out")
if _, err := os.Stat(streamPath); os.IsNotExist(err) {
// Stream file doesn't exist - this might be an orphaned session
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Session %s missing stream-out file, marking as exited", id[:8])
}
// Mark session as exited if it claims to be running but has no stream file
if info.Status == string(StatusRunning) {
info.Status = string(StatusExited)
exitCode := 1
info.ExitCode = &exitCode
if err := info.Save(sessionPath); err != nil {
log.Printf("[ERROR] Failed to save session info to %s: %v", sessionPath, err)
}
}
}
// 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 {
if err := pty.Close(); err != nil {
log.Printf("[ERROR] Failed to close PTY: %v", err)
}
return fmt.Errorf("failed to update session info: %w", err)
}
go func() {
if err := s.pty.Run(); err != nil {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Session %s: PTY.Run() exited with error: %v", s.ID[:8], err)
}
} else {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Session %s: PTY.Run() exited normally", s.ID[:8])
}
}
}()
// Start control listener
s.startControlListener()
// Process status will be checked on first access - no artificial delay needed
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
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()
}
// AttachSpawnedSession is used when a terminal is spawned with TTY_SESSION_ID
// It creates a new PTY for the spawned terminal and runs the command
func (s *Session) AttachSpawnedSession() error {
// Create a new PTY for this spawned session
pty, err := NewPTY(s)
if err != nil {
return fmt.Errorf("failed to create PTY: %w", err)
}
s.pty = pty
// Update session status
s.info.Status = string(StatusRunning)
s.info.Pid = pty.Pid()
if err := s.info.Save(s.Path()); err != nil {
if err := pty.Close(); err != nil {
log.Printf("[ERROR] Failed to close PTY: %v", err)
}
return fmt.Errorf("failed to update session info: %w", err)
}
// Create a channel to signal when PTY.Run() completes
ptyDone := make(chan struct{})
// Start the PTY I/O loop in a goroutine (like Start() does)
go func() {
defer close(ptyDone)
if err := s.pty.Run(); err != nil {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Session %s: PTY.Run() exited with error: %v", s.ID[:8], err)
}
} else {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Session %s: PTY.Run() exited normally", s.ID[:8])
}
}
// Ensure session status is updated
s.UpdateStatus()
}()
// Start control listener
s.startControlListener()
// Attach to the PTY to connect stdin/stdout
attachErr := s.pty.Attach()
// Wait a moment for PTY cleanup to complete
select {
case <-ptyDone:
// PTY.Run() already completed
case <-time.After(500 * time.Millisecond):
// Give it a bit more time to update status
s.UpdateStatus()
}
return attachErr
}
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 {
// If pipe fails, try Node.js proxy fallback like Rust
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Failed to open stdin pipe, trying Node.js proxy fallback: %v", err)
}
return s.proxyInputToNodeJS(data)
}
s.stdinPipe = pipe
}
_, err := s.stdinPipe.Write(data)
if err != nil {
// If write fails, close and reset the pipe for next attempt
if err := s.stdinPipe.Close(); err != nil {
log.Printf("[ERROR] Failed to close stdin pipe: %v", err)
}
s.stdinPipe = nil
// Try Node.js proxy fallback like Rust
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Failed to write to stdin pipe, trying Node.js proxy fallback: %v", err)
}
return s.proxyInputToNodeJS(data)
}
return nil
}
// proxyInputToNodeJS sends input via Node.js server fallback (like Rust implementation)
func (s *Session) proxyInputToNodeJS(data []byte) error {
client := &http.Client{
Timeout: 5 * time.Second,
}
url := fmt.Sprintf("http://localhost:3000/api/sessions/%s/input", s.ID)
payload := map[string]interface{}{
"data": string(data),
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal input data: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create proxy request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("node.js proxy fallback failed: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Printf("[WARN] Failed to close response body: %v", err)
}
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("node.js proxy returned status %d: %s", resp.StatusCode, string(body))
}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Successfully sent input via Node.js proxy for session %s", s.ID[:8])
}
return nil
}
func (s *Session) Signal(sig string) error {
if s.info.Pid == 0 {
return NewSessionError("no process to signal", ErrProcessNotFound, s.ID)
}
// Check if process is still alive before signaling
if !s.IsAlive() {
// Process is already dead, update status and return success
s.info.Status = string(StatusExited)
exitCode := 0
s.info.ExitCode = &exitCode
if err := s.info.Save(s.Path()); err != nil {
log.Printf("[ERROR] Failed to save session info: %v", err)
}
return nil
}
proc, err := os.FindProcess(s.info.Pid)
if err != nil {
return ErrProcessSignalError(s.ID, sig, err)
}
switch sig {
case "SIGTERM":
if err := proc.Signal(os.Interrupt); err != nil {
return ErrProcessSignalError(s.ID, sig, err)
}
return nil
case "SIGKILL":
err = proc.Kill()
// If kill fails with "process already finished", that's okay
if err != nil && strings.Contains(err.Error(), "process already finished") {
return nil
}
if err != nil {
return ErrProcessSignalError(s.ID, sig, err)
}
return nil
default:
return NewSessionError(fmt.Sprintf("unsupported signal: %s", sig), ErrInvalidArgument, s.ID)
}
}
func (s *Session) Stop() error {
return s.Signal("SIGTERM")
}
func (s *Session) Kill() error {
// Use graceful termination like Node.js
terminator := NewProcessTerminator(s)
return terminator.TerminateGracefully()
}
// KillWithSignal kills the session with the specified signal
// If signal is SIGKILL, it sends it immediately without graceful termination
func (s *Session) KillWithSignal(signal string) error {
// If SIGKILL is explicitly requested, send it immediately
if signal == "SIGKILL" || signal == "9" {
err := s.Signal("SIGKILL")
s.cleanup()
// If the error is because the process doesn't exist, that's fine
if err != nil && (strings.Contains(err.Error(), "no such process") ||
strings.Contains(err.Error(), "process already finished")) {
return nil
}
return err
}
// For other signals, use graceful termination
return s.Kill()
}
func (s *Session) cleanup() {
s.stdinMutex.Lock()
defer s.stdinMutex.Unlock()
if s.stdinPipe != nil {
if err := s.stdinPipe.Close(); err != nil {
log.Printf("[ERROR] Failed to close stdin pipe: %v", err)
}
s.stdinPipe = nil
}
}
func (s *Session) Resize(width, height int) error {
// Check if resizing is disabled globally
if s.manager != nil && s.manager.GetDoNotAllowColumnSet() {
return NewSessionError("terminal resizing is disabled by server configuration", ErrInvalidInput, s.ID)
}
// Check if session is still alive
if s.info.Status == string(StatusExited) {
return NewSessionError("cannot resize exited session", ErrSessionNotRunning, s.ID)
}
// Validate dimensions
if width <= 0 || height <= 0 {
return NewSessionError(
fmt.Sprintf("invalid dimensions: width=%d, height=%d", width, height),
ErrInvalidArgument,
s.ID,
)
}
// 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)
}
// If this is a spawned session, send resize command through control FIFO
if s.IsSpawned() {
cmd := &ControlCommand{
Cmd: "resize",
Cols: width,
Rows: height,
}
return SendControlCommand(s.Path(), cmd)
}
// For non-spawned sessions, resize the PTY directly
if s.pty == nil {
return NewSessionError("session not started", ErrSessionNotRunning, s.ID)
}
return s.pty.Resize(width, height)
}
func (s *Session) IsAlive() bool {
s.mu.RLock()
pid := s.info.Pid
status := s.info.Status
s.mu.RUnlock()
if pid == 0 {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: PID is 0 for session %s", s.ID[:8])
}
return false
}
// If already marked as exited, don't check again
if status == string(StatusExited) {
return false
}
// On Windows, use gopsutil (no kill() available)
if runtime.GOOS == "windows" {
exists, err := process.PidExists(int32(pid))
if err != nil {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: Windows gopsutil failed for PID %d: %v", pid, err)
}
return false
}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: Windows gopsutil PidExists for PID %d: %t (session %s)", pid, exists, s.ID[:8])
}
return exists
}
// On POSIX systems (Linux, macOS, FreeBSD, etc.), use efficient kill(pid, 0)
osProcess, err := os.FindProcess(pid)
if err != nil {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: POSIX FindProcess failed for PID %d: %v", pid, err)
}
return false
}
// Send signal 0 to check if process exists (POSIX only)
err = osProcess.Signal(syscall.Signal(0))
if err != nil {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: POSIX kill(0) failed for PID %d: %v", pid, err)
}
return false
}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: POSIX kill(0) confirmed PID %d is alive (session %s)", pid, s.ID[:8])
}
return true
}
// IsSpawned returns whether this session was spawned in a terminal
func (s *Session) IsSpawned() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.info.IsSpawned
}
func (s *Session) UpdateStatus() error {
if s.info.Status == string(StatusExited) {
return nil
}
alive := s.IsAlive()
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] UpdateStatus for session %s: PID=%d, alive=%v", s.ID[:8], s.info.Pid, alive)
}
if !alive {
s.info.Status = string(StatusExited)
exitCode := 0
s.info.ExitCode = &exitCode
return s.info.Save(s.Path())
}
return nil
}
// GetInfo returns the session info
func (s *Session) GetInfo() *Info {
s.mu.RLock()
defer s.mu.RUnlock()
return s.info
}
func (i *Info) Save(sessionPath string) error {
// Convert to Rust format for saving
rustInfo := RustSessionInfo{
ID: i.ID,
Name: i.Name,
Cmdline: i.Args, // Use Args array instead of Cmdline string
Cwd: i.Cwd,
Status: i.Status,
ExitCode: i.ExitCode,
Term: i.Term,
SpawnType: "pty", // Default spawn type
Cols: &i.Width,
Rows: &i.Height,
Env: i.Env,
}
// Only include Pid if non-zero
if i.Pid > 0 {
rustInfo.Pid = &i.Pid
}
// Only include StartedAt if not zero time
if !i.StartedAt.IsZero() {
rustInfo.StartedAt = &i.StartedAt
}
data, err := json.MarshalIndent(rustInfo, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(sessionPath, "session.json"), data, 0644)
}
// RustSessionInfo represents the session format used by the Rust server
type RustSessionInfo struct {
ID string `json:"id,omitempty"`
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,omitempty"`
Term string `json:"term"`
SpawnType string `json:"spawn_type,omitempty"`
Cols *int `json:"cols,omitempty"`
Rows *int `json:"rows,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
func LoadInfo(sessionPath string) (*Info, error) {
data, err := os.ReadFile(filepath.Join(sessionPath, "session.json"))
if err != nil {
return nil, err
}
// Load Rust format (the only format we support)
var rustInfo RustSessionInfo
if err := json.Unmarshal(data, &rustInfo); err != nil {
return nil, fmt.Errorf("failed to parse session.json: %w", err)
}
// Convert Rust format to internal Info format
info := Info{
ID: rustInfo.ID,
Name: rustInfo.Name,
Cmdline: strings.Join(rustInfo.Cmdline, " "),
Cwd: rustInfo.Cwd,
Status: rustInfo.Status,
ExitCode: rustInfo.ExitCode,
Term: rustInfo.Term,
Args: rustInfo.Cmdline,
Env: rustInfo.Env,
}
// Handle PID conversion
if rustInfo.Pid != nil {
info.Pid = *rustInfo.Pid
}
// Handle dimensions: use cols/rows if available, otherwise defaults
if rustInfo.Cols != nil {
info.Width = *rustInfo.Cols
} else {
info.Width = 120
}
if rustInfo.Rows != nil {
info.Height = *rustInfo.Rows
} else {
info.Height = 30
}
// Handle timestamp
if rustInfo.StartedAt != nil {
info.StartedAt = *rustInfo.StartedAt
} else {
info.StartedAt = time.Now()
}
// If ID is empty (Rust doesn't store it in JSON), derive it from directory name
if info.ID == "" {
info.ID = filepath.Base(sessionPath)
}
return &info, nil
}