mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-02 10:45:57 +00:00
563 lines
14 KiB
Go
563 lines
14 KiB
Go
package session
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestPTYEventDriven tests basic PTY operation with event-driven I/O
|
|
func TestPTYEventDriven(t *testing.T) {
|
|
if !useEventDrivenIO {
|
|
t.Skip("Event-driven I/O is disabled")
|
|
}
|
|
|
|
// Create temporary directory for session
|
|
tmpDir, err := ioutil.TempDir("", "pty-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create session
|
|
session := &Session{
|
|
ID: "test-session",
|
|
controlPath: tmpDir,
|
|
info: &Info{
|
|
ID: "test-session",
|
|
Name: "test",
|
|
Cmdline: "echo",
|
|
Args: []string{"echo", "Hello from PTY"},
|
|
Cwd: tmpDir,
|
|
Status: "created",
|
|
Term: "xterm",
|
|
Width: 80,
|
|
Height: 24,
|
|
},
|
|
}
|
|
|
|
// Create necessary directories
|
|
if err := os.MkdirAll(session.Path(), 0755); err != nil {
|
|
t.Fatalf("Failed to create session dir: %v", err)
|
|
}
|
|
|
|
// Create stdin pipe
|
|
if err := syscall.Mkfifo(session.StdinPath(), 0600); err != nil {
|
|
t.Fatalf("Failed to create stdin pipe: %v", err)
|
|
}
|
|
|
|
// Create PTY
|
|
pty, err := NewPTY(session)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PTY: %v", err)
|
|
}
|
|
|
|
// Capture output
|
|
streamOut := filepath.Join(session.Path(), "stream-out")
|
|
outputData := &bytes.Buffer{}
|
|
|
|
// Run PTY with event-driven I/O
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- pty.Run()
|
|
}()
|
|
|
|
// Wait for process to complete
|
|
select {
|
|
case err := <-done:
|
|
if err != nil && !strings.Contains(err.Error(), "signal:") {
|
|
t.Errorf("PTY.Run() failed: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("PTY.Run() timeout")
|
|
}
|
|
|
|
// Read output from stream file
|
|
if data, err := ioutil.ReadFile(streamOut); err == nil {
|
|
outputData.Write(data)
|
|
}
|
|
|
|
// Verify output contains expected text
|
|
output := outputData.String()
|
|
if !strings.Contains(output, "Hello from PTY") {
|
|
t.Errorf("Expected output to contain 'Hello from PTY', got: %s", output)
|
|
}
|
|
|
|
// Verify process exited
|
|
if session.info.Status != "exited" {
|
|
t.Errorf("Expected status 'exited', got: %s", session.info.Status)
|
|
}
|
|
}
|
|
|
|
// TestPTYEventDrivenInput tests input handling with event-driven I/O
|
|
func TestPTYEventDrivenInput(t *testing.T) {
|
|
if !useEventDrivenIO {
|
|
t.Skip("Event-driven I/O is disabled")
|
|
}
|
|
|
|
// Create temporary directory
|
|
tmpDir, err := ioutil.TempDir("", "pty-input-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create session for cat command (echoes input)
|
|
session := &Session{
|
|
ID: "test-input-session",
|
|
controlPath: tmpDir,
|
|
info: &Info{
|
|
ID: "test-input-session",
|
|
Name: "test-input",
|
|
Cmdline: "cat",
|
|
Args: []string{"cat"},
|
|
Cwd: tmpDir,
|
|
Status: "created",
|
|
Term: "xterm",
|
|
Width: 80,
|
|
Height: 24,
|
|
},
|
|
}
|
|
|
|
// Create directories and pipes
|
|
if err := os.MkdirAll(session.Path(), 0755); err != nil {
|
|
t.Fatalf("Failed to create session dir: %v", err)
|
|
}
|
|
|
|
if err := syscall.Mkfifo(session.StdinPath(), 0600); err != nil {
|
|
t.Fatalf("Failed to create stdin pipe: %v", err)
|
|
}
|
|
|
|
// Create PTY
|
|
pty, err := NewPTY(session)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PTY: %v", err)
|
|
}
|
|
|
|
// Start PTY
|
|
ptyClosed := make(chan error, 1)
|
|
go func() {
|
|
ptyClosed <- pty.Run()
|
|
}()
|
|
|
|
// Give PTY time to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Send input through stdin pipe
|
|
stdinPipe, err := os.OpenFile(session.StdinPath(), os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open stdin pipe: %v", err)
|
|
}
|
|
|
|
testInput := "Hello Event Loop!\n"
|
|
if _, err := stdinPipe.Write([]byte(testInput)); err != nil {
|
|
t.Errorf("Failed to write to stdin: %v", err)
|
|
}
|
|
|
|
// Send EOF to terminate cat
|
|
stdinPipe.Write([]byte{4}) // Ctrl+D
|
|
stdinPipe.Close()
|
|
|
|
// Wait for PTY to exit
|
|
select {
|
|
case <-ptyClosed:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("PTY didn't exit after EOF")
|
|
}
|
|
|
|
// Read output
|
|
streamOut := filepath.Join(session.Path(), "stream-out")
|
|
data, err := ioutil.ReadFile(streamOut)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read output: %v", err)
|
|
}
|
|
|
|
// Parse asciinema format to extract output
|
|
lines := strings.Split(string(data), "\n")
|
|
var output string
|
|
for _, line := range lines {
|
|
if strings.Contains(line, `"o"`) && strings.Contains(line, testInput) {
|
|
output += testInput
|
|
}
|
|
}
|
|
|
|
if !strings.Contains(output, strings.TrimSpace(testInput)) {
|
|
t.Errorf("Expected output to contain %q, got: %s", testInput, output)
|
|
}
|
|
}
|
|
|
|
// TestPTYEventDrivenPerformance compares event-driven vs polling PTY performance
|
|
func TestPTYEventDrivenPerformance(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping performance test in short mode")
|
|
}
|
|
|
|
// Test configuration
|
|
lineCount := 1000
|
|
lineLength := 80
|
|
|
|
// Generate test script that outputs many lines
|
|
script := fmt.Sprintf(`#!/bin/bash
|
|
for i in $(seq 1 %d); do
|
|
echo "%s"
|
|
done
|
|
`, lineCount, strings.Repeat("x", lineLength))
|
|
|
|
// Test event-driven
|
|
eventDrivenTime := runPTYPerformanceTest(t, script, true)
|
|
|
|
// Test polling
|
|
pollingTime := runPTYPerformanceTest(t, script, false)
|
|
|
|
t.Logf("Event-driven: %v, Polling: %v", eventDrivenTime, pollingTime)
|
|
t.Logf("Event-driven is %.2fx faster", float64(pollingTime)/float64(eventDrivenTime))
|
|
|
|
// Event-driven should be noticeably faster
|
|
if eventDrivenTime > time.Duration(float64(pollingTime)*0.9) {
|
|
t.Logf("Warning: Event-driven performance not significantly better than polling")
|
|
}
|
|
}
|
|
|
|
func runPTYPerformanceTest(t *testing.T, script string, useEventDriven bool) time.Duration {
|
|
// Temporarily set event-driven flag
|
|
oldValue := useEventDrivenIO
|
|
useEventDrivenIO = useEventDriven
|
|
defer func() { useEventDrivenIO = oldValue }()
|
|
|
|
// Create temp directory
|
|
tmpDir, err := ioutil.TempDir("", "pty-perf-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Write script
|
|
scriptPath := filepath.Join(tmpDir, "test.sh")
|
|
if err := ioutil.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
t.Fatalf("Failed to write script: %v", err)
|
|
}
|
|
|
|
// Create session
|
|
session := &Session{
|
|
ID: "perf-test",
|
|
controlPath: tmpDir,
|
|
info: &Info{
|
|
ID: "perf-test",
|
|
Name: "perf-test",
|
|
Cmdline: scriptPath,
|
|
Args: []string{scriptPath},
|
|
Cwd: tmpDir,
|
|
Status: "created",
|
|
Term: "xterm",
|
|
Width: 80,
|
|
Height: 24,
|
|
},
|
|
}
|
|
|
|
// Create directories
|
|
if err := os.MkdirAll(session.Path(), 0755); err != nil {
|
|
t.Fatalf("Failed to create session dir: %v", err)
|
|
}
|
|
|
|
if err := syscall.Mkfifo(session.StdinPath(), 0600); err != nil {
|
|
t.Fatalf("Failed to create stdin pipe: %v", err)
|
|
}
|
|
|
|
// Create PTY
|
|
pty, err := NewPTY(session)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PTY: %v", err)
|
|
}
|
|
|
|
// Measure execution time
|
|
start := time.Now()
|
|
|
|
if err := pty.Run(); err != nil && !strings.Contains(err.Error(), "signal:") {
|
|
t.Errorf("PTY.Run() failed: %v", err)
|
|
}
|
|
|
|
return time.Since(start)
|
|
}
|
|
|
|
// TestPTYEventDrivenResize tests terminal resize handling
|
|
func TestPTYEventDrivenResize(t *testing.T) {
|
|
if !useEventDrivenIO {
|
|
t.Skip("Event-driven I/O is disabled")
|
|
}
|
|
|
|
// Create temp directory
|
|
tmpDir, err := ioutil.TempDir("", "pty-resize-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create session with a command that reports terminal size
|
|
session := &Session{
|
|
ID: "resize-test",
|
|
controlPath: tmpDir,
|
|
info: &Info{
|
|
ID: "resize-test",
|
|
Name: "resize-test",
|
|
Cmdline: "bash",
|
|
Args: []string{"bash", "-c", "trap 'echo COLUMNS=$COLUMNS LINES=$LINES' WINCH; sleep 2"},
|
|
Cwd: tmpDir,
|
|
Status: "created",
|
|
Term: "xterm",
|
|
Width: 80,
|
|
Height: 24,
|
|
},
|
|
}
|
|
|
|
// Create directories
|
|
if err := os.MkdirAll(session.Path(), 0755); err != nil {
|
|
t.Fatalf("Failed to create session dir: %v", err)
|
|
}
|
|
|
|
if err := syscall.Mkfifo(session.StdinPath(), 0600); err != nil {
|
|
t.Fatalf("Failed to create stdin pipe: %v", err)
|
|
}
|
|
|
|
// Create control FIFO for resize commands
|
|
controlPath := filepath.Join(session.Path(), "control")
|
|
if err := syscall.Mkfifo(controlPath, 0600); err != nil {
|
|
t.Fatalf("Failed to create control pipe: %v", err)
|
|
}
|
|
|
|
// Create PTY
|
|
pty, err := NewPTY(session)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PTY: %v", err)
|
|
}
|
|
|
|
// Start PTY
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- pty.Run()
|
|
}()
|
|
|
|
// Give process time to start
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Send resize command
|
|
if err := pty.Resize(120, 40); err != nil {
|
|
t.Errorf("Failed to resize PTY: %v", err)
|
|
}
|
|
|
|
// Wait for completion
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("PTY didn't exit")
|
|
}
|
|
|
|
// Check if resize was handled (this is somewhat fragile as it depends on bash behavior)
|
|
streamOut := filepath.Join(session.Path(), "stream-out")
|
|
if data, err := ioutil.ReadFile(streamOut); err == nil {
|
|
output := string(data)
|
|
if strings.Contains(output, "COLUMNS=120 LINES=40") {
|
|
t.Log("Resize event was properly handled")
|
|
} else {
|
|
t.Log("Resize event may not have been triggered (bash-specific test)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPTYEventDrivenConcurrent tests concurrent PTY sessions
|
|
func TestPTYEventDrivenConcurrent(t *testing.T) {
|
|
if !useEventDrivenIO {
|
|
t.Skip("Event-driven I/O is disabled")
|
|
}
|
|
|
|
if testing.Short() {
|
|
t.Skip("Skipping concurrent test in short mode")
|
|
}
|
|
|
|
// Number of concurrent PTYs
|
|
ptyCount := 20
|
|
|
|
// Track results
|
|
var wg sync.WaitGroup
|
|
errors := make(chan error, ptyCount)
|
|
successCount := atomic.Int32{}
|
|
|
|
// Create and run multiple PTYs concurrently
|
|
for i := 0; i < ptyCount; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
|
|
// Create temp directory
|
|
tmpDir, err := ioutil.TempDir("", fmt.Sprintf("pty-concurrent-%d-*", idx))
|
|
if err != nil {
|
|
errors <- fmt.Errorf("PTY %d: failed to create temp dir: %v", idx, err)
|
|
return
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create session
|
|
session := &Session{
|
|
ID: fmt.Sprintf("concurrent-%d", idx),
|
|
controlPath: tmpDir,
|
|
info: &Info{
|
|
ID: fmt.Sprintf("concurrent-%d", idx),
|
|
Name: fmt.Sprintf("test-%d", idx),
|
|
Cmdline: "echo",
|
|
Args: []string{"echo", fmt.Sprintf("Output from PTY %d", idx)},
|
|
Cwd: tmpDir,
|
|
Status: "created",
|
|
Term: "xterm",
|
|
Width: 80,
|
|
Height: 24,
|
|
},
|
|
}
|
|
|
|
// Create directories
|
|
if err := os.MkdirAll(session.Path(), 0755); err != nil {
|
|
errors <- fmt.Errorf("PTY %d: failed to create session dir: %v", idx, err)
|
|
return
|
|
}
|
|
|
|
if err := syscall.Mkfifo(session.StdinPath(), 0600); err != nil {
|
|
errors <- fmt.Errorf("PTY %d: failed to create stdin pipe: %v", idx, err)
|
|
return
|
|
}
|
|
|
|
// Create and run PTY
|
|
pty, err := NewPTY(session)
|
|
if err != nil {
|
|
errors <- fmt.Errorf("PTY %d: failed to create PTY: %v", idx, err)
|
|
return
|
|
}
|
|
|
|
if err := pty.Run(); err != nil && !strings.Contains(err.Error(), "signal:") {
|
|
errors <- fmt.Errorf("PTY %d: Run() failed: %v", idx, err)
|
|
return
|
|
}
|
|
|
|
// Verify output
|
|
streamOut := filepath.Join(session.Path(), "stream-out")
|
|
if data, err := ioutil.ReadFile(streamOut); err == nil {
|
|
if strings.Contains(string(data), fmt.Sprintf("Output from PTY %d", idx)) {
|
|
successCount.Add(1)
|
|
} else {
|
|
errors <- fmt.Errorf("PTY %d: output mismatch", idx)
|
|
}
|
|
} else {
|
|
errors <- fmt.Errorf("PTY %d: failed to read output: %v", idx, err)
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all PTYs to complete
|
|
wg.Wait()
|
|
close(errors)
|
|
|
|
// Check for errors
|
|
errorCount := 0
|
|
for err := range errors {
|
|
t.Errorf("Concurrent PTY error: %v", err)
|
|
errorCount++
|
|
}
|
|
|
|
// Verify success rate
|
|
t.Logf("Successful PTYs: %d/%d", successCount.Load(), ptyCount)
|
|
if successCount.Load() < int32(ptyCount*9/10) { // Allow 10% failure rate
|
|
t.Errorf("Too many failures: %d/%d succeeded", successCount.Load(), ptyCount)
|
|
}
|
|
}
|
|
|
|
// TestPTYEventDrivenCleanup tests proper cleanup on exit
|
|
func TestPTYEventDrivenCleanup(t *testing.T) {
|
|
if !useEventDrivenIO {
|
|
t.Skip("Event-driven I/O is disabled")
|
|
}
|
|
|
|
// Create temp directory
|
|
tmpDir, err := ioutil.TempDir("", "pty-cleanup-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Track file descriptors before test
|
|
fdCountBefore := countOpenFileDescriptors(t)
|
|
|
|
// Run multiple PTY sessions
|
|
for i := 0; i < 5; i++ {
|
|
session := &Session{
|
|
ID: fmt.Sprintf("cleanup-%d", i),
|
|
controlPath: tmpDir,
|
|
info: &Info{
|
|
ID: fmt.Sprintf("cleanup-%d", i),
|
|
Name: "cleanup-test",
|
|
Cmdline: "true",
|
|
Args: []string{"true"},
|
|
Cwd: tmpDir,
|
|
Status: "created",
|
|
Term: "xterm",
|
|
Width: 80,
|
|
Height: 24,
|
|
},
|
|
}
|
|
|
|
if err := os.MkdirAll(session.Path(), 0755); err != nil {
|
|
t.Fatalf("Failed to create session dir: %v", err)
|
|
}
|
|
|
|
if err := syscall.Mkfifo(session.StdinPath(), 0600); err != nil {
|
|
t.Fatalf("Failed to create stdin pipe: %v", err)
|
|
}
|
|
|
|
pty, err := NewPTY(session)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PTY: %v", err)
|
|
}
|
|
|
|
if err := pty.Run(); err != nil && !strings.Contains(err.Error(), "signal:") {
|
|
t.Errorf("PTY.Run() failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Force garbage collection
|
|
runtime.GC()
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Check file descriptors after test
|
|
fdCountAfter := countOpenFileDescriptors(t)
|
|
|
|
// Allow some tolerance for system file descriptors
|
|
if fdCountAfter > fdCountBefore+5 {
|
|
t.Errorf("Possible file descriptor leak: before=%d, after=%d", fdCountBefore, fdCountAfter)
|
|
}
|
|
}
|
|
|
|
func countOpenFileDescriptors(t *testing.T) int {
|
|
// Count open file descriptors (Linux/macOS specific)
|
|
pid := os.Getpid()
|
|
fdPath := fmt.Sprintf("/proc/%d/fd", pid)
|
|
|
|
// Try Linux proc filesystem first
|
|
if entries, err := ioutil.ReadDir(fdPath); err == nil {
|
|
return len(entries)
|
|
}
|
|
|
|
// Try macOS/BSD approach
|
|
fdPath = fmt.Sprintf("/dev/fd")
|
|
if entries, err := ioutil.ReadDir(fdPath); err == nil {
|
|
return len(entries)
|
|
}
|
|
|
|
// Can't count, return 0
|
|
t.Log("Cannot count file descriptors on this platform")
|
|
return 0
|
|
}
|