mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-26 09:35:52 +00:00
650 lines
20 KiB
Go
650 lines
20 KiB
Go
package session
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
"github.com/vibetunnel/linux/pkg/protocol"
|
|
"golang.org/x/sys/unix"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// useSelectPolling determines whether to use select-based polling
|
|
// Enable this for better control FIFO integration
|
|
const useSelectPolling = true
|
|
|
|
// isShellBuiltin checks if a command is a shell builtin
|
|
func isShellBuiltin(cmd string) bool {
|
|
builtins := []string{
|
|
"cd", "echo", "pwd", "export", "alias", "source", ".",
|
|
"unset", "set", "eval", "exec", "exit", "return",
|
|
"break", "continue", "shift", "trap", "wait", "umask",
|
|
"ulimit", "times", "test", "[", "[[", "type", "hash",
|
|
"help", "history", "jobs", "kill", "let", "local",
|
|
"logout", "popd", "pushd", "read", "readonly", "true",
|
|
"false", ":", "printf", "declare", "typeset", "unalias",
|
|
}
|
|
|
|
for _, builtin := range builtins {
|
|
if cmd == builtin {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type PTY struct {
|
|
session *Session
|
|
cmd *exec.Cmd
|
|
pty *os.File
|
|
oldState *term.State
|
|
streamWriter *protocol.StreamWriter
|
|
stdinPipe *os.File
|
|
useEventDrivenStdin bool
|
|
resizeMutex sync.Mutex
|
|
}
|
|
|
|
func NewPTY(session *Session) (*PTY, error) {
|
|
debugLog("[DEBUG] NewPTY: Starting PTY creation for session %s", session.ID[:8])
|
|
|
|
shell := os.Getenv("SHELL")
|
|
if shell == "" {
|
|
shell = "/bin/bash"
|
|
}
|
|
|
|
cmdline := session.info.Args
|
|
if len(cmdline) == 0 {
|
|
cmdline = []string{shell}
|
|
}
|
|
|
|
debugLog("[DEBUG] NewPTY: Initial cmdline: %v", cmdline)
|
|
|
|
// Execute through shell to handle aliases, functions, and proper PATH resolution
|
|
var cmd *exec.Cmd
|
|
if len(cmdline) == 1 && cmdline[0] == shell {
|
|
// If just launching the shell itself, don't use -c
|
|
cmd = exec.Command(shell)
|
|
} else {
|
|
// Execute command through login shell for proper environment handling
|
|
// This ensures aliases and functions from .zshrc/.bashrc are loaded
|
|
shellCmd := strings.Join(cmdline, " ")
|
|
|
|
// Check if this is a shell builtin command
|
|
if isShellBuiltin(cmdline[0]) {
|
|
// For builtins, we don't need interactive mode
|
|
cmd = exec.Command(shell, "-c", shellCmd)
|
|
debugLog("[DEBUG] NewPTY: Executing builtin command: %s -c %q", shell, shellCmd)
|
|
} else if strings.Contains(shell, "zsh") {
|
|
// For zsh, use login shell to load configurations
|
|
// Interactive mode (-i) can cause issues with some commands
|
|
cmd = exec.Command(shell, "-l", "-c", shellCmd)
|
|
debugLog("[DEBUG] NewPTY: Executing through zsh login shell: %s -l -c %q", shell, shellCmd)
|
|
} else {
|
|
// For other shells (bash, sh), use interactive login
|
|
// This ensures aliases and functions are available
|
|
cmd = exec.Command(shell, "-i", "-l", "-c", shellCmd)
|
|
debugLog("[DEBUG] NewPTY: Executing through interactive login shell: %s -i -l -c %q", shell, shellCmd)
|
|
}
|
|
|
|
// Add some debugging to understand what's happening
|
|
debugLog("[DEBUG] NewPTY: Shell: %s", shell)
|
|
debugLog("[DEBUG] NewPTY: Command: %v", cmdline)
|
|
debugLog("[DEBUG] NewPTY: Shell command: %s", shellCmd)
|
|
}
|
|
|
|
// Set working directory, ensuring it's valid
|
|
if session.info.Cwd != "" {
|
|
// Verify the directory exists and is accessible
|
|
if _, err := os.Stat(session.info.Cwd); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Working directory '%s' not accessible: %v", session.info.Cwd, err)
|
|
return nil, NewSessionErrorWithCause(
|
|
fmt.Sprintf("working directory '%s' not accessible", session.info.Cwd),
|
|
ErrInvalidArgument,
|
|
session.ID,
|
|
err,
|
|
)
|
|
}
|
|
cmd.Dir = session.info.Cwd
|
|
debugLog("[DEBUG] NewPTY: Set working directory to: %s", session.info.Cwd)
|
|
}
|
|
|
|
// Pass all environment variables like Node.js implementation does
|
|
// This ensures terminal features, locale settings, and shell prompts work correctly
|
|
env := os.Environ()
|
|
|
|
// Log PATH for debugging
|
|
pathFound := false
|
|
for _, e := range env {
|
|
if strings.HasPrefix(e, "PATH=") {
|
|
debugLog("[DEBUG] NewPTY: PATH=%s", e[5:])
|
|
pathFound = true
|
|
break
|
|
}
|
|
}
|
|
if !pathFound {
|
|
debugLog("[DEBUG] NewPTY: No PATH found in environment!")
|
|
}
|
|
|
|
// Override TERM if specified in session info
|
|
termSet := false
|
|
for i, v := range env {
|
|
if strings.HasPrefix(v, "TERM=") {
|
|
env[i] = "TERM=" + session.info.Term
|
|
termSet = true
|
|
break
|
|
}
|
|
}
|
|
if !termSet {
|
|
env = append(env, "TERM="+session.info.Term)
|
|
}
|
|
|
|
cmd.Env = env
|
|
|
|
ptmx, err := pty.Start(cmd)
|
|
if err != nil {
|
|
// Provide more helpful error message for common failures
|
|
errorMsg := fmt.Sprintf("Failed to start PTY for command '%s'", strings.Join(cmdline, " "))
|
|
if strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "not found") {
|
|
errorMsg = fmt.Sprintf("Command '%s' not found. Make sure it's installed and in your PATH, or is a valid shell alias/function. The command was executed through %s to load your shell configuration.", cmdline[0], shell)
|
|
} else if strings.Contains(err.Error(), "permission denied") {
|
|
errorMsg = fmt.Sprintf("Permission denied executing '%s'", strings.Join(cmdline, " "))
|
|
}
|
|
log.Printf("[ERROR] NewPTY: %s: %v", errorMsg, err)
|
|
log.Printf("[ERROR] NewPTY: Shell used: %s, Working directory: %s", shell, session.info.Cwd)
|
|
return nil, NewSessionErrorWithCause(errorMsg, ErrPTYCreationFailed, session.ID, err)
|
|
}
|
|
|
|
debugLog("[DEBUG] NewPTY: PTY started successfully, PID: %d", cmd.Process.Pid)
|
|
|
|
// Log the actual command being executed
|
|
debugLog("[DEBUG] NewPTY: Executing command: %v in directory: %s", cmdline, cmd.Dir)
|
|
debugLog("[DEBUG] NewPTY: Environment has %d variables", len(cmd.Env))
|
|
|
|
// Configure terminal attributes to match node-pty behavior
|
|
// This must be done before setting size and after the process starts
|
|
if err := configurePTYTerminal(ptmx); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to configure PTY terminal: %v", err)
|
|
// Don't fail on terminal configuration errors, just log them
|
|
}
|
|
|
|
|
|
// Set PTY size using our enhanced function
|
|
if err := setPTYSize(ptmx, uint16(session.info.Width), uint16(session.info.Height)); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to set PTY size: %v", err)
|
|
if err := ptmx.Close(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to close PTY: %v", err)
|
|
}
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to kill process: %v", err)
|
|
}
|
|
return nil, NewSessionErrorWithCause(
|
|
"failed to set PTY size",
|
|
ErrPTYResizeFailed,
|
|
session.ID,
|
|
err,
|
|
)
|
|
}
|
|
|
|
debugLog("[DEBUG] NewPTY: Terminal configured for interactive mode with flow control")
|
|
|
|
streamOut, err := os.Create(session.StreamOutPath())
|
|
if err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to create stream-out: %v", err)
|
|
if err := ptmx.Close(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to close PTY: %v", err)
|
|
}
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to kill process: %v", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to create stream-out: %w", err)
|
|
}
|
|
|
|
streamWriter := protocol.NewStreamWriter(streamOut, &protocol.AsciinemaHeader{
|
|
Version: 2,
|
|
Width: uint32(session.info.Width),
|
|
Height: uint32(session.info.Height),
|
|
Command: strings.Join(cmdline, " "),
|
|
Env: session.info.Env,
|
|
})
|
|
|
|
if err := streamWriter.WriteHeader(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to write stream header: %v", err)
|
|
if err := streamOut.Close(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to close stream-out: %v", err)
|
|
}
|
|
if err := ptmx.Close(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to close PTY: %v", err)
|
|
}
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to kill process: %v", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to write stream header: %w", err)
|
|
}
|
|
|
|
stdinPath := session.StdinPath()
|
|
debugLog("[DEBUG] NewPTY: Creating stdin FIFO at: %s", stdinPath)
|
|
if err := syscall.Mkfifo(stdinPath, 0600); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to create stdin pipe: %v", err)
|
|
if err := streamOut.Close(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to close stream-out: %v", err)
|
|
}
|
|
if err := ptmx.Close(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to close PTY: %v", err)
|
|
}
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to kill process: %v", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
|
|
}
|
|
|
|
// Create control FIFO
|
|
if err := session.createControlFIFO(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to create control FIFO: %v", err)
|
|
// Don't fail if control FIFO creation fails - it's optional
|
|
}
|
|
|
|
ptyObj := &PTY{
|
|
session: session,
|
|
cmd: cmd,
|
|
pty: ptmx,
|
|
streamWriter: streamWriter,
|
|
}
|
|
|
|
// For spawned sessions that will be attached, disable echo immediately
|
|
// to prevent race condition where output is processed before Attach() disables echo
|
|
if session.info.IsSpawned {
|
|
debugLog("[DEBUG] NewPTY: Spawned session detected, disabling PTY echo immediately")
|
|
if err := ptyObj.disablePTYEcho(); err != nil {
|
|
log.Printf("[ERROR] NewPTY: Failed to disable PTY echo for spawned session: %v", err)
|
|
}
|
|
}
|
|
|
|
return ptyObj, nil
|
|
}
|
|
|
|
func (p *PTY) Pid() int {
|
|
if p.cmd.Process != nil {
|
|
return p.cmd.Process.Pid
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *PTY) Run() error {
|
|
defer func() {
|
|
if err := p.Close(); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to close PTY: %v", err)
|
|
}
|
|
}()
|
|
|
|
debugLog("[DEBUG] PTY.Run: Starting PTY run for session %s, PID %d", p.session.ID[:8], p.cmd.Process.Pid)
|
|
|
|
// Use event-driven stdin handling like Node.js
|
|
stdinWatcher, err := NewStdinWatcher(p.session.StdinPath(), p.pty)
|
|
if err != nil {
|
|
// Fall back to polling if watcher fails
|
|
log.Printf("[WARN] PTY.Run: Failed to create stdin watcher, falling back to polling: %v", err)
|
|
|
|
stdinPipe, err := os.OpenFile(p.session.StdinPath(), os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
|
if err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to open stdin pipe: %v", err)
|
|
return fmt.Errorf("failed to open stdin pipe: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := stdinPipe.Close(); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to close stdin pipe: %v", err)
|
|
}
|
|
}()
|
|
p.stdinPipe = stdinPipe
|
|
} else {
|
|
// Start the watcher
|
|
stdinWatcher.Start()
|
|
defer stdinWatcher.Stop()
|
|
p.useEventDrivenStdin = true
|
|
debugLog("[DEBUG] PTY.Run: Using event-driven stdin handling")
|
|
}
|
|
|
|
debugLog("[DEBUG] PTY.Run: Stdin handling initialized")
|
|
|
|
// Set up SIGWINCH handling for terminal resize
|
|
winchCh := make(chan os.Signal, 1)
|
|
signal.Notify(winchCh, syscall.SIGWINCH)
|
|
defer signal.Stop(winchCh)
|
|
|
|
// Handle SIGWINCH in a separate goroutine
|
|
go func() {
|
|
for range winchCh {
|
|
// Check if resizing is disabled globally
|
|
if p.session.manager != nil && p.session.manager.GetDoNotAllowColumnSet() {
|
|
debugLog("[DEBUG] PTY.Run: Received SIGWINCH but resizing is disabled by server configuration")
|
|
continue
|
|
}
|
|
|
|
// Get current terminal size if we're attached to a terminal
|
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
|
width, height, err := term.GetSize(int(os.Stdin.Fd()))
|
|
if err == nil {
|
|
debugLog("[DEBUG] PTY.Run: Received SIGWINCH, resizing to %dx%d", width, height)
|
|
if err := setPTYSize(p.pty, uint16(width), uint16(height)); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to resize PTY: %v", err)
|
|
} else {
|
|
// Update session info
|
|
p.session.mu.Lock()
|
|
p.session.info.Width = width
|
|
p.session.info.Height = height
|
|
p.session.mu.Unlock()
|
|
|
|
// Write resize event to stream
|
|
if err := p.streamWriter.WriteResize(uint32(width), uint32(height)); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to write resize event: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Use select-based polling if available
|
|
if useSelectPolling {
|
|
return p.pollWithSelect()
|
|
}
|
|
|
|
// Fallback to goroutine-based implementation
|
|
errCh := make(chan error, 3)
|
|
|
|
go func() {
|
|
debugLog("[DEBUG] PTY.Run: Starting output reading goroutine")
|
|
buf := make([]byte, 1024) // 1KB buffer for maximum responsiveness
|
|
|
|
for {
|
|
// Use a timeout-based approach for cross-platform compatibility
|
|
// This avoids the complexity of non-blocking I/O syscalls
|
|
n, err := p.pty.Read(buf)
|
|
if n > 0 {
|
|
debugLog("[DEBUG] PTY.Run: Read %d bytes of output from PTY", n)
|
|
if err := p.streamWriter.WriteOutput(buf[:n]); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to write output: %v", err)
|
|
errCh <- fmt.Errorf("failed to write output: %w", err)
|
|
return
|
|
}
|
|
// Continue reading immediately if we got data
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
// For blocking reads, EOF typically means the process exited
|
|
debugLog("[DEBUG] PTY.Run: PTY reached EOF, process likely exited")
|
|
return
|
|
}
|
|
// For other errors, this is a problem
|
|
log.Printf("[ERROR] PTY.Run: OUTPUT GOROUTINE sending error to errCh: %v", err)
|
|
errCh <- fmt.Errorf("PTY read error: %w", err)
|
|
return
|
|
}
|
|
// If we get here, n == 0 and err == nil, which is unusual for blocking reads
|
|
// Give a longer pause to prevent excessive CPU usage
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
// Only start stdin goroutine if not using event-driven mode
|
|
if !p.useEventDrivenStdin && p.stdinPipe != nil {
|
|
go func() {
|
|
debugLog("[DEBUG] PTY.Run: Starting stdin reading goroutine")
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := p.stdinPipe.Read(buf)
|
|
if n > 0 {
|
|
debugLog("[DEBUG] PTY.Run: Read %d bytes from stdin, writing to PTY", n)
|
|
if _, err := p.pty.Write(buf[:n]); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to write to PTY: %v", err)
|
|
// Only exit if the PTY is really broken, not on temporary errors
|
|
if err != syscall.EPIPE && err != syscall.ECONNRESET {
|
|
errCh <- fmt.Errorf("failed to write to PTY: %w", err)
|
|
return
|
|
}
|
|
// For broken pipe, just continue - the PTY might be closing
|
|
debugLog("[DEBUG] PTY.Run: PTY write failed with pipe error, continuing...")
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
// Continue immediately after successful write
|
|
continue
|
|
}
|
|
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
|
|
// No data available, longer pause to prevent excessive CPU usage
|
|
time.Sleep(10 * time.Millisecond)
|
|
continue
|
|
}
|
|
if err == io.EOF {
|
|
// No writers to the FIFO yet, longer pause before retry
|
|
time.Sleep(50 * time.Millisecond)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
// Log other errors but don't crash the session - stdin issues shouldn't kill the PTY
|
|
log.Printf("[WARN] PTY.Run: Stdin read error (non-fatal): %v", err)
|
|
time.Sleep(10 * time.Millisecond)
|
|
continue
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
debugLog("[DEBUG] PTY.Run: Starting process wait goroutine for PID %d", p.cmd.Process.Pid)
|
|
err := p.cmd.Wait()
|
|
debugLog("[DEBUG] PTY.Run: Process wait completed for PID %d, error: %v", p.cmd.Process.Pid, err)
|
|
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
|
|
exitCode := status.ExitStatus()
|
|
p.session.info.ExitCode = &exitCode
|
|
debugLog("[DEBUG] PTY.Run: Process exited with code %d", exitCode)
|
|
}
|
|
} else {
|
|
debugLog("[DEBUG] PTY.Run: Process exited with non-exit error: %v", err)
|
|
}
|
|
} else {
|
|
exitCode := 0
|
|
p.session.info.ExitCode = &exitCode
|
|
debugLog("[DEBUG] PTY.Run: Process exited normally (code 0)")
|
|
}
|
|
p.session.info.Status = string(StatusExited)
|
|
if err := p.session.info.Save(p.session.Path()); err != nil {
|
|
log.Printf("[ERROR] PTY.Run: Failed to save session info: %v", err)
|
|
}
|
|
|
|
// Reap any zombie child processes
|
|
for {
|
|
var status syscall.WaitStatus
|
|
pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil)
|
|
if err != nil || pid <= 0 {
|
|
break
|
|
}
|
|
debugLog("[DEBUG] PTY.Run: Reaped zombie process PID %d", pid)
|
|
}
|
|
|
|
debugLog("[DEBUG] PTY.Run: PROCESS WAIT GOROUTINE sending completion to errCh")
|
|
errCh <- err
|
|
}()
|
|
|
|
debugLog("[DEBUG] PTY.Run: Waiting for first error from goroutines...")
|
|
result := <-errCh
|
|
debugLog("[DEBUG] PTY.Run: Received error from goroutine: %v", result)
|
|
debugLog("[DEBUG] PTY.Run: Process PID %d status after error: alive=%v", p.cmd.Process.Pid, p.session.IsAlive())
|
|
return result
|
|
}
|
|
|
|
func (p *PTY) Attach() error {
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
return fmt.Errorf("not a terminal")
|
|
}
|
|
|
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set raw mode: %w", err)
|
|
}
|
|
p.oldState = oldState
|
|
|
|
// When attaching to a PTY interactively, we need to disable ECHO on the PTY
|
|
// to prevent double-echoing (since the controlling terminal is in raw mode)
|
|
if err := p.disablePTYEcho(); err != nil {
|
|
log.Printf("[WARN] PTY.Attach: Failed to disable PTY echo: %v", err)
|
|
// Continue anyway - some programs might handle this themselves
|
|
}
|
|
|
|
defer func() {
|
|
if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil {
|
|
log.Printf("[ERROR] PTY.Attach: Failed to restore terminal: %v", err)
|
|
}
|
|
}()
|
|
|
|
ch := make(chan os.Signal, 1)
|
|
signal.Notify(ch, syscall.SIGWINCH)
|
|
go func() {
|
|
for range ch {
|
|
// Check if resizing is disabled globally
|
|
if p.session.manager != nil && p.session.manager.GetDoNotAllowColumnSet() {
|
|
debugLog("[DEBUG] PTY.Attach: Received SIGWINCH but resizing is disabled by server configuration")
|
|
continue
|
|
}
|
|
if err := p.updateSize(); err != nil {
|
|
log.Printf("[ERROR] PTY.Attach: Failed to update size: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
defer signal.Stop(ch)
|
|
|
|
// Only update size initially if resizing is allowed
|
|
if p.session.manager == nil || !p.session.manager.GetDoNotAllowColumnSet() {
|
|
if err := p.updateSize(); err != nil {
|
|
log.Printf("[ERROR] PTY.Attach: Failed to update initial size: %v", err)
|
|
}
|
|
} else {
|
|
debugLog("[DEBUG] PTY.Attach: Skipping initial resize - resizing is disabled by server configuration")
|
|
}
|
|
|
|
errCh := make(chan error, 2)
|
|
|
|
go func() {
|
|
_, err := io.Copy(p.pty, os.Stdin)
|
|
errCh <- err
|
|
}()
|
|
|
|
go func() {
|
|
_, err := io.Copy(os.Stdout, p.pty)
|
|
errCh <- err
|
|
}()
|
|
|
|
return <-errCh
|
|
}
|
|
|
|
func (p *PTY) updateSize() error {
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
return nil
|
|
}
|
|
|
|
width, height, err := term.GetSize(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return pty.Setsize(p.pty, &pty.Winsize{
|
|
Rows: uint16(height),
|
|
Cols: uint16(width),
|
|
})
|
|
}
|
|
|
|
// disablePTYEcho disables echo on the PTY to prevent double-echoing
|
|
// when the controlling terminal is in raw mode
|
|
func (p *PTY) disablePTYEcho() error {
|
|
// Get current PTY termios
|
|
termios, err := unix.IoctlGetTermios(int(p.pty.Fd()), unix.TIOCGETA)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get PTY termios: %w", err)
|
|
}
|
|
|
|
// Disable echo flags to prevent double-echoing
|
|
// Keep other flags like ICANON for line processing
|
|
termios.Lflag &^= unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHOKE | unix.ECHOCTL
|
|
|
|
// Apply the new settings
|
|
if err := unix.IoctlSetTermios(int(p.pty.Fd()), unix.TIOCSETA, termios); err != nil {
|
|
return fmt.Errorf("failed to set PTY termios: %w", err)
|
|
}
|
|
|
|
debugLog("[DEBUG] PTY.disablePTYEcho: Disabled echo on PTY")
|
|
return nil
|
|
}
|
|
|
|
func (p *PTY) Resize(width, height int) error {
|
|
if p.pty == nil {
|
|
return fmt.Errorf("PTY not initialized")
|
|
}
|
|
|
|
p.resizeMutex.Lock()
|
|
defer p.resizeMutex.Unlock()
|
|
|
|
debugLog("[DEBUG] PTY.Resize: Resizing PTY to %dx%d for session %s", width, height, p.session.ID[:8])
|
|
|
|
// Resize the actual PTY
|
|
err := pty.Setsize(p.pty, &pty.Winsize{
|
|
Rows: uint16(height),
|
|
Cols: uint16(width),
|
|
})
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] PTY.Resize: Failed to resize PTY: %v", err)
|
|
return fmt.Errorf("failed to resize PTY: %w", err)
|
|
}
|
|
|
|
// Write resize event to stream if streamWriter is available
|
|
if p.streamWriter != nil {
|
|
if err := p.streamWriter.WriteResize(uint32(width), uint32(height)); err != nil {
|
|
log.Printf("[ERROR] PTY.Resize: Failed to write resize event: %v", err)
|
|
// Don't fail the resize operation if we can't write the event
|
|
}
|
|
}
|
|
|
|
debugLog("[DEBUG] PTY.Resize: Successfully resized PTY to %dx%d", width, height)
|
|
return nil
|
|
}
|
|
|
|
func (p *PTY) Close() error {
|
|
var firstErr error
|
|
|
|
if p.streamWriter != nil {
|
|
if err := p.streamWriter.Close(); err != nil {
|
|
log.Printf("[ERROR] PTY.Close: Failed to close stream writer: %v", err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
if p.pty != nil {
|
|
if err := p.pty.Close(); err != nil {
|
|
log.Printf("[ERROR] PTY.Close: Failed to close PTY: %v", err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
if p.oldState != nil {
|
|
if err := term.Restore(int(os.Stdin.Fd()), p.oldState); err != nil {
|
|
log.Printf("[ERROR] PTY.Close: Failed to restore terminal: %v", err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|