mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-26 09:35:52 +00:00
- Fix hanging TestNewStdinWatcher by not calling Stop() without Start() - Fix TestSession_Signal and TestSession_KillWithSignal by adding PID values - Fix isProcessRunning to use syscall.Signal(0) instead of os.Signal(nil) - Update websocket test to expect new 'Unknown WebSocket endpoint' error message - Add timeout handling to websocket integration test
158 lines
4.3 KiB
Go
158 lines
4.3 KiB
Go
package session
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ProcessTerminator provides graceful process termination with timeout
|
|
// Matches the Node.js implementation behavior
|
|
type ProcessTerminator struct {
|
|
session *Session
|
|
gracefulTimeout time.Duration
|
|
checkInterval time.Duration
|
|
}
|
|
|
|
// NewProcessTerminator creates a new process terminator
|
|
func NewProcessTerminator(session *Session) *ProcessTerminator {
|
|
return &ProcessTerminator{
|
|
session: session,
|
|
gracefulTimeout: 3 * time.Second, // Match Node.js 3 second timeout
|
|
checkInterval: 500 * time.Millisecond, // Match Node.js 500ms check interval
|
|
}
|
|
}
|
|
|
|
// TerminateGracefully attempts graceful termination with escalation to SIGKILL
|
|
// This matches the Node.js implementation behavior:
|
|
// 1. Send SIGTERM
|
|
// 2. Wait up to 3 seconds for graceful termination
|
|
// 3. Send SIGKILL if process is still alive
|
|
func (pt *ProcessTerminator) TerminateGracefully() error {
|
|
sessionID := pt.session.ID[:8]
|
|
pid := pt.session.info.Pid
|
|
|
|
// Check if already exited
|
|
if pt.session.info.Status == string(StatusExited) {
|
|
debugLog("[DEBUG] ProcessTerminator: Session %s already exited", sessionID)
|
|
pt.session.cleanup()
|
|
return nil
|
|
}
|
|
|
|
if pid == 0 {
|
|
return NewSessionError("no process to terminate", ErrProcessNotFound, pt.session.ID)
|
|
}
|
|
|
|
log.Printf("[INFO] Terminating session %s (PID: %d) with SIGTERM...", sessionID, pid)
|
|
|
|
// Send SIGTERM first
|
|
if err := pt.session.Signal("SIGTERM"); err != nil {
|
|
// If process doesn't exist, that's fine
|
|
if !pt.session.IsAlive() {
|
|
log.Printf("[INFO] Session %s already terminated", sessionID)
|
|
pt.session.cleanup()
|
|
return nil
|
|
}
|
|
// If it's already a SessionError, return as-is
|
|
if se, ok := err.(*SessionError); ok {
|
|
return se
|
|
}
|
|
return NewSessionErrorWithCause("failed to send SIGTERM", ErrProcessTerminateFailed, pt.session.ID, err)
|
|
}
|
|
|
|
// Wait for graceful termination
|
|
startTime := time.Now()
|
|
checkCount := 0
|
|
maxChecks := int(pt.gracefulTimeout / pt.checkInterval)
|
|
|
|
for checkCount < maxChecks {
|
|
// Wait for check interval
|
|
time.Sleep(pt.checkInterval)
|
|
checkCount++
|
|
|
|
// Check if process is still alive
|
|
if !pt.session.IsAlive() {
|
|
elapsed := time.Since(startTime)
|
|
log.Printf("[INFO] Session %s terminated gracefully after %dms", sessionID, elapsed.Milliseconds())
|
|
pt.session.cleanup()
|
|
return nil
|
|
}
|
|
|
|
// Log progress
|
|
elapsed := time.Since(startTime)
|
|
log.Printf("[INFO] Session %s still alive after %dms...", sessionID, elapsed.Milliseconds())
|
|
}
|
|
|
|
// Process didn't terminate gracefully, force kill
|
|
log.Printf("[INFO] Session %s didn't terminate gracefully, sending SIGKILL...", sessionID)
|
|
|
|
if err := pt.session.Signal("SIGKILL"); err != nil {
|
|
// If process doesn't exist anymore, that's fine
|
|
if !pt.session.IsAlive() {
|
|
log.Printf("[INFO] Session %s terminated before SIGKILL", sessionID)
|
|
pt.session.cleanup()
|
|
return nil
|
|
}
|
|
// If it's already a SessionError, return as-is
|
|
if se, ok := err.(*SessionError); ok {
|
|
return se
|
|
}
|
|
return NewSessionErrorWithCause("failed to send SIGKILL", ErrProcessTerminateFailed, pt.session.ID, err)
|
|
}
|
|
|
|
// Wait a bit for SIGKILL to take effect
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
if pt.session.IsAlive() {
|
|
log.Printf("[WARN] Session %s may still be alive after SIGKILL", sessionID)
|
|
} else {
|
|
log.Printf("[INFO] Session %s forcefully terminated with SIGKILL", sessionID)
|
|
}
|
|
|
|
pt.session.cleanup()
|
|
return nil
|
|
}
|
|
|
|
// waitForProcessExit waits for a process to exit with timeout
|
|
// Returns true if process exited within timeout, false otherwise
|
|
func waitForProcessExit(pid int, timeout time.Duration) bool {
|
|
startTime := time.Now()
|
|
checkInterval := 100 * time.Millisecond
|
|
|
|
for time.Since(startTime) < timeout {
|
|
// Try to find the process
|
|
proc, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
// Process doesn't exist
|
|
return true
|
|
}
|
|
|
|
// Check if process is alive using signal 0
|
|
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
|
// Process doesn't exist or we don't have permission
|
|
return true
|
|
}
|
|
|
|
time.Sleep(checkInterval)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isProcessRunning checks if a process is running by PID
|
|
// Uses platform-appropriate methods
|
|
func isProcessRunning(pid int) bool {
|
|
if pid <= 0 {
|
|
return false
|
|
}
|
|
|
|
proc, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// On Unix, signal 0 checks if process exists
|
|
err = proc.Signal(syscall.Signal(0))
|
|
return err == nil
|
|
}
|