vibetunnel/linux/pkg/session/pty_eventloop_test.go
2025-06-21 02:49:38 +02:00

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
}