e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
679 lines
18 KiB
Go
679 lines
18 KiB
Go
// Copyright 2025 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//go:build !windows
|
|
// +build !windows
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/alibaba/opensandbox/internal/safego"
|
|
"github.com/creack/pty"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/log"
|
|
"github.com/alibaba/opensandbox/execd/pkg/util/pathutil"
|
|
)
|
|
|
|
// PTYSession is the public interface for an interactive PTY/pipe session.
|
|
// The concrete implementation (*ptySession) is unexported; callers outside
|
|
// this package must use this interface.
|
|
type PTYSession interface {
|
|
LockWS() bool
|
|
UnlockWS()
|
|
TakeoverWS(timeout time.Duration) bool
|
|
SetEvictHandler(fn func()) uint64
|
|
ClearEvictHandler(gen uint64)
|
|
IsRunning() bool
|
|
IsPTY() bool
|
|
ExitCode() int
|
|
Done() <-chan struct{}
|
|
StartPTY() error
|
|
StartPipe() error
|
|
WriteStdin(p []byte) (int, error)
|
|
AttachOutput() (io.Reader, io.Reader, func())
|
|
AttachOutputWithSnapshot(since int64) (io.Reader, io.Reader, func(), []byte, int64)
|
|
SendSignal(name string)
|
|
ResizePTY(cols, rows uint16) error
|
|
}
|
|
|
|
// IsPTYSessionSupported reports whether PTY sessions are supported on this platform.
|
|
func IsPTYSessionSupported() bool { return true }
|
|
|
|
func NewPTYSessionID() string {
|
|
return uuidString()
|
|
}
|
|
|
|
// ptySession manages a single interactive PTY or pipe-mode bash process.
|
|
//
|
|
// Lifecycle:
|
|
// 1. Create via newPTYSession.
|
|
// 2. Call StartPTY() or StartPipe() from the WS handler (after LockWS).
|
|
// 3. Zero or more clients call AttachOutput() to receive live output.
|
|
// 4. The bash process exits → Done() closes → exit frame sent.
|
|
// 5. Call close() to terminate an early session and release resources.
|
|
type ptySession struct {
|
|
id string
|
|
cwd string
|
|
command string // optional custom command; defaults to bash if empty
|
|
|
|
mu sync.Mutex
|
|
closing bool
|
|
|
|
// Process tracking (guarded by mu)
|
|
pid int // PID of the running bash process (0 = not running)
|
|
lastExitCode int // exit code; -1 until process exits
|
|
doneCh chan struct{} // closed when process exits (non-nil after Start*)
|
|
|
|
// Stdin (PTY master in PTY mode; write end of os.Pipe in pipe mode)
|
|
stdin io.WriteCloser
|
|
|
|
// PTY-specific
|
|
isPTY bool
|
|
ptmx *os.File // PTY master fd; nil in pipe mode
|
|
|
|
// Replay
|
|
replay *replayBuffer
|
|
|
|
// WS exclusive lock: only one WebSocket client at a time.
|
|
wsConnected atomic.Bool
|
|
|
|
// Eviction hook for session takeover. The active WS handler registers a function
|
|
// that closes its own connection (and stops its pumps) so a newer client can take
|
|
// over the session. Guarded by evictMu; evictGen lets a handler clear only its own
|
|
// hook and never a successor's (see SetEvictHandler / ClearEvictHandler).
|
|
evictMu sync.Mutex
|
|
evict func()
|
|
evictGen uint64
|
|
|
|
// Output broadcast (guards stdoutW / stderrW).
|
|
// The broadcast goroutine holds outMu only while reading the pointer; writes
|
|
// to the pipe happen outside the lock to avoid blocking broadcast on slow clients.
|
|
outMu sync.Mutex
|
|
stdoutW *io.PipeWriter // current per-connection sink; nil when no client attached
|
|
stderrW *io.PipeWriter // nil in PTY mode
|
|
}
|
|
|
|
func newPTYSession(id, cwd, command string) *ptySession {
|
|
return &ptySession{
|
|
id: id,
|
|
cwd: cwd,
|
|
command: command,
|
|
replay: newReplayBuffer(),
|
|
lastExitCode: -1,
|
|
}
|
|
}
|
|
|
|
// LockWS attempts to acquire the exclusive WebSocket connection lock.
|
|
// Returns true on success, false if another client is already connected.
|
|
func (s *ptySession) LockWS() bool {
|
|
return s.wsConnected.CompareAndSwap(false, true)
|
|
}
|
|
|
|
// UnlockWS releases the WebSocket connection lock.
|
|
func (s *ptySession) UnlockWS() {
|
|
s.wsConnected.Store(false)
|
|
}
|
|
|
|
// SetEvictHandler registers fn as the current connection's eviction hook and returns
|
|
// a generation token. A newer handler calling this overwrites the previous hook. Pass
|
|
// the returned token to ClearEvictHandler on teardown.
|
|
func (s *ptySession) SetEvictHandler(fn func()) uint64 {
|
|
s.evictMu.Lock()
|
|
defer s.evictMu.Unlock()
|
|
s.evictGen++
|
|
s.evict = fn
|
|
return s.evictGen
|
|
}
|
|
|
|
// ClearEvictHandler removes the eviction hook only if it still belongs to gen, so a
|
|
// handler tearing down never clears a successor's hook (which would race after a
|
|
// takeover hands the session to a new connection).
|
|
func (s *ptySession) ClearEvictHandler(gen uint64) {
|
|
s.evictMu.Lock()
|
|
defer s.evictMu.Unlock()
|
|
if s.evictGen == gen {
|
|
s.evict = nil
|
|
}
|
|
}
|
|
|
|
// triggerEvict invokes the current eviction hook, if any. The hook is expected to be
|
|
// idempotent (closing an already-closed WS is a no-op), so repeated calls are safe.
|
|
func (s *ptySession) triggerEvict() {
|
|
s.evictMu.Lock()
|
|
fn := s.evict
|
|
s.evictMu.Unlock()
|
|
if fn != nil {
|
|
fn()
|
|
}
|
|
}
|
|
|
|
// TakeoverWS forcibly acquires the WS lock for a new client. It repeatedly evicts the
|
|
// current holder (closing its WS) and retries LockWS until it wins or timeout elapses.
|
|
// The shell process keeps running throughout; the new client reattaches with replay.
|
|
// Returns true if the lock was acquired.
|
|
func (s *ptySession) TakeoverWS(timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
if s.LockWS() {
|
|
return true
|
|
}
|
|
s.triggerEvict()
|
|
if time.Now().After(deadline) {
|
|
// One last attempt in case the holder released just now.
|
|
return s.LockWS()
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// IsRunning returns true if the bash process is currently alive.
|
|
func (s *ptySession) IsRunning() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.pid != 0
|
|
}
|
|
|
|
// IsPTY returns true when the session was started in PTY mode.
|
|
func (s *ptySession) IsPTY() bool {
|
|
return s.isPTY
|
|
}
|
|
|
|
// ExitCode returns the exit code of the last process, or -1 if it has not exited yet.
|
|
func (s *ptySession) ExitCode() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.lastExitCode
|
|
}
|
|
|
|
// Done returns a channel that is closed when the bash process exits.
|
|
// Returns nil if the process has not been started yet.
|
|
func (s *ptySession) Done() <-chan struct{} {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.doneCh
|
|
}
|
|
|
|
// ReplayBuffer returns the session's replay buffer (thread-safe).
|
|
func (s *ptySession) ReplayBuffer() *replayBuffer {
|
|
return s.replay
|
|
}
|
|
|
|
// StartPTY launches bash via pty.StartWithSize.
|
|
// Must be called with the WS lock held.
|
|
func (s *ptySession) StartPTY() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.pid != 0 {
|
|
return errors.New("pty session already started")
|
|
}
|
|
if s.closing {
|
|
return errors.New("pty session is closing")
|
|
}
|
|
|
|
cmdArgs := []string{"--norc", "--noprofile"}
|
|
if s.command != "" {
|
|
cmdArgs = append(cmdArgs, "-c", s.command)
|
|
}
|
|
cmd := exec.Command("bash", cmdArgs...)
|
|
cmd.Env = os.Environ()
|
|
if s.cwd != "" {
|
|
cmd.Dir = s.cwd
|
|
}
|
|
// Do NOT set Setpgid: pty.StartWithSize sets Setsid+Setctty internally.
|
|
// Combining Setsid+Setpgid causes EPERM (setpgid is illegal for a session leader).
|
|
|
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 80, Rows: 24})
|
|
if err != nil {
|
|
return fmt.Errorf("pty.StartWithSize: %w", err)
|
|
}
|
|
|
|
s.ptmx = ptmx
|
|
s.isPTY = true
|
|
s.pid = cmd.Process.Pid
|
|
s.doneCh = make(chan struct{})
|
|
s.stdin = ptmx // write to the PTY master to feed stdin
|
|
|
|
safego.Go(func() { s.broadcastPTY() })
|
|
safego.Go(func() { s.waitAndExit(cmd, ptmx) })
|
|
|
|
return nil
|
|
}
|
|
|
|
// StartPipe launches bash with plain stdin/stdout/stderr os.Pipes.
|
|
// Must be called with the WS lock held.
|
|
func (s *ptySession) StartPipe() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.pid != 0 {
|
|
return errors.New("pty session already started")
|
|
}
|
|
if s.closing {
|
|
return errors.New("pty session is closing")
|
|
}
|
|
|
|
stdinR, stdinW, err := os.Pipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
stdoutR, stdoutW, err := os.Pipe()
|
|
if err != nil {
|
|
_ = stdinR.Close()
|
|
_ = stdinW.Close()
|
|
return fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderrR, stderrW, err := os.Pipe()
|
|
if err != nil {
|
|
_ = stdinR.Close()
|
|
_ = stdinW.Close()
|
|
_ = stdoutR.Close()
|
|
_ = stdoutW.Close()
|
|
return fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
cmdArgs := []string{"--norc", "--noprofile"}
|
|
if s.command != "" {
|
|
cmdArgs = append(cmdArgs, "-c", s.command)
|
|
}
|
|
cmd := exec.Command("bash", cmdArgs...)
|
|
cmd.Env = os.Environ()
|
|
if s.cwd != "" {
|
|
cmd.Dir = s.cwd
|
|
}
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Stdin = stdinR
|
|
cmd.Stdout = stdoutW
|
|
cmd.Stderr = stderrW
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
_ = stdinR.Close()
|
|
_ = stdinW.Close()
|
|
_ = stdoutR.Close()
|
|
_ = stdoutW.Close()
|
|
_ = stderrR.Close()
|
|
_ = stderrW.Close()
|
|
return fmt.Errorf("cmd.Start: %w", err)
|
|
}
|
|
|
|
// Close the child-side ends in the parent — the child has its own copies.
|
|
_ = stdinR.Close()
|
|
_ = stdoutW.Close()
|
|
_ = stderrW.Close()
|
|
|
|
s.isPTY = false
|
|
s.pid = cmd.Process.Pid
|
|
s.doneCh = make(chan struct{})
|
|
s.stdin = stdinW
|
|
|
|
safego.Go(func() { s.broadcastPipe(stdoutR, true) })
|
|
safego.Go(func() { s.broadcastPipe(stderrR, false) })
|
|
safego.Go(func() { s.waitAndExitPipe(cmd, stdinW, stdoutR, stderrR) })
|
|
|
|
return nil
|
|
}
|
|
|
|
// broadcastPTY reads from the PTY master and fans out to replay + active WS client.
|
|
func (s *ptySession) broadcastPTY() {
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := s.ptmx.Read(buf)
|
|
if n > 0 {
|
|
s.writeAndFanout(buf[:n], true)
|
|
}
|
|
if err != nil {
|
|
// EIO or EOF when the child exits — normal termination
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastPipe reads from a pipe (stdout or stderr) and fans out to replay + active WS client.
|
|
func (s *ptySession) broadcastPipe(r *os.File, isStdout bool) {
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := r.Read(buf)
|
|
if n > 0 {
|
|
s.writeAndFanout(buf[:n], isStdout)
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
_ = r.Close()
|
|
}
|
|
|
|
// writeAndFanout writes chunk to the replay buffer and delivers it to the
|
|
// active per-connection pipe, atomically under outMu.
|
|
//
|
|
// Holding outMu across both operations closes the window where bytes written
|
|
// to replay after ReadFrom but before AttachOutput would be silently dropped.
|
|
// Lock order is always outMu → replay.mu (both paths), so no deadlock is possible.
|
|
func (s *ptySession) writeAndFanout(chunk []byte, isStdout bool) {
|
|
s.outMu.Lock()
|
|
s.replay.write(chunk) // acquires replay.mu inside (outMu → replay.mu)
|
|
var w *io.PipeWriter
|
|
if isStdout {
|
|
w = s.stdoutW
|
|
} else {
|
|
w = s.stderrW
|
|
}
|
|
s.outMu.Unlock()
|
|
|
|
if w != nil {
|
|
if _, err := w.Write(chunk); err != nil {
|
|
// Pipe was closed (client detached) — ignore.
|
|
log.Warn("pty fanout write: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitAndExit waits for the PTY-mode process and updates session state on exit.
|
|
func (s *ptySession) waitAndExit(cmd *exec.Cmd, ptmx *os.File) {
|
|
_ = cmd.Wait()
|
|
|
|
// Close the PTY master to unblock the broadcast goroutine.
|
|
_ = ptmx.Close()
|
|
|
|
s.mu.Lock()
|
|
exitCode := 0
|
|
if cmd.ProcessState != nil {
|
|
exitCode = cmd.ProcessState.ExitCode()
|
|
}
|
|
s.lastExitCode = exitCode
|
|
s.pid = 0
|
|
doneCh := s.doneCh
|
|
s.mu.Unlock()
|
|
|
|
close(doneCh)
|
|
}
|
|
|
|
// waitAndExitPipe waits for the pipe-mode process and updates session state on exit.
|
|
func (s *ptySession) waitAndExitPipe(cmd *exec.Cmd, stdinW, stdoutR, stderrR *os.File) {
|
|
_ = cmd.Wait()
|
|
|
|
// Close stdin write-end so the child (if still running) sees EOF.
|
|
_ = stdinW.Close()
|
|
|
|
s.mu.Lock()
|
|
exitCode := 0
|
|
if cmd.ProcessState != nil {
|
|
exitCode = cmd.ProcessState.ExitCode()
|
|
}
|
|
s.lastExitCode = exitCode
|
|
s.pid = 0
|
|
doneCh := s.doneCh
|
|
s.mu.Unlock()
|
|
|
|
close(doneCh)
|
|
}
|
|
|
|
// WriteStdin writes p to bash stdin (PTY master or pipe write-end).
|
|
func (s *ptySession) WriteStdin(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
w := s.stdin
|
|
s.mu.Unlock()
|
|
if w == nil {
|
|
return 0, errors.New("session not started")
|
|
}
|
|
return w.Write(p)
|
|
}
|
|
|
|
// AttachOutput creates a fresh per-connection io.Pipe and swaps it into the
|
|
// broadcast fanout path.
|
|
//
|
|
// Ordering guarantee (no duplicates on reconnect):
|
|
// - Caller must snapshot the replay buffer BEFORE calling AttachOutput.
|
|
// - Bytes produced between the snapshot and AttachOutput are delivered via
|
|
// the live pipe only (not in the snapshot), so each byte arrives exactly once.
|
|
//
|
|
// Returns (stdout reader, stderr reader [nil in PTY mode], detach func).
|
|
// Calling detach() closes the writers, sending EOF to the readers and
|
|
// unblocking all pump goroutines.
|
|
func (s *ptySession) AttachOutput() (io.Reader, io.Reader, func()) {
|
|
stdoutR, stdoutW := io.Pipe()
|
|
|
|
s.outMu.Lock()
|
|
s.stdoutW = stdoutW
|
|
s.outMu.Unlock()
|
|
|
|
if s.isPTY {
|
|
detach := func() {
|
|
s.outMu.Lock()
|
|
s.stdoutW = nil
|
|
s.outMu.Unlock()
|
|
_ = stdoutW.Close()
|
|
}
|
|
return stdoutR, nil, detach
|
|
}
|
|
|
|
// Pipe mode: also attach stderr.
|
|
stderrR, stderrW := io.Pipe()
|
|
|
|
s.outMu.Lock()
|
|
s.stderrW = stderrW
|
|
s.outMu.Unlock()
|
|
|
|
detach := func() {
|
|
s.outMu.Lock()
|
|
s.stdoutW = nil
|
|
s.stderrW = nil
|
|
s.outMu.Unlock()
|
|
_ = stdoutW.Close()
|
|
_ = stderrW.Close()
|
|
}
|
|
return stdoutR, stderrR, detach
|
|
}
|
|
|
|
// AttachOutputWithSnapshot atomically snapshots the replay buffer and attaches
|
|
// the per-connection output pipe, eliminating the output-loss window that exists
|
|
// when ReadFrom and AttachOutput are called separately.
|
|
//
|
|
// Must be used together with writeAndFanout (which holds outMu during both
|
|
// replay.write and the fanout pointer read).
|
|
//
|
|
// Lock order is always outMu → replay.mu (both paths), so no deadlock is possible.
|
|
//
|
|
// Returns (stdoutR, stderrR [nil in PTY mode], detach, snapshotBytes, snapshotOffset).
|
|
func (s *ptySession) AttachOutputWithSnapshot(since int64) (io.Reader, io.Reader, func(), []byte, int64) {
|
|
stdoutR, stdoutW := io.Pipe()
|
|
var stderrR io.Reader
|
|
var stderrW *io.PipeWriter
|
|
if !s.isPTY {
|
|
stderrR, stderrW = io.Pipe()
|
|
}
|
|
|
|
s.outMu.Lock()
|
|
snapshotBytes, snapshotOffset := s.replay.ReadFrom(since) // acquires replay.mu inside
|
|
s.stdoutW = stdoutW
|
|
if stderrW != nil {
|
|
s.stderrW = stderrW
|
|
}
|
|
s.outMu.Unlock()
|
|
|
|
detach := func() {
|
|
s.outMu.Lock()
|
|
s.stdoutW = nil
|
|
if stderrW != nil {
|
|
s.stderrW = nil
|
|
}
|
|
s.outMu.Unlock()
|
|
_ = stdoutW.Close()
|
|
if stderrW != nil {
|
|
_ = stderrW.Close()
|
|
}
|
|
}
|
|
return stdoutR, stderrR, detach, snapshotBytes, snapshotOffset
|
|
}
|
|
|
|
// SendSignal sends the named signal to the process group.
|
|
// Recognised names: SIGINT, SIGTERM, SIGKILL, SIGQUIT, SIGHUP.
|
|
func (s *ptySession) SendSignal(name string) {
|
|
s.mu.Lock()
|
|
pid := s.pid
|
|
s.mu.Unlock()
|
|
if pid == 0 {
|
|
return
|
|
}
|
|
|
|
sig := parseSignalName(name)
|
|
if sig == 0 {
|
|
log.Warn("ptySession.SendSignal: unknown signal %q", name)
|
|
return
|
|
}
|
|
|
|
// In PTY mode (setsid), pgid == pid automatically.
|
|
// In pipe mode (Setpgid), pgid is also == pid.
|
|
// Either way, Kill(-pid, sig) sends to the process group.
|
|
if err := syscall.Kill(-pid, sig); err != nil {
|
|
log.Warn("ptySession.SendSignal kill(-%d, %v): %v", pid, sig, err)
|
|
}
|
|
}
|
|
|
|
func parseSignalName(name string) syscall.Signal {
|
|
switch name {
|
|
case "SIGINT":
|
|
return syscall.SIGINT
|
|
case "SIGTERM":
|
|
return syscall.SIGTERM
|
|
case "SIGKILL":
|
|
return syscall.SIGKILL
|
|
case "SIGQUIT":
|
|
return syscall.SIGQUIT
|
|
case "SIGHUP":
|
|
return syscall.SIGHUP
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// ResizePTY updates the terminal window size (PTY mode only; no-op in pipe mode).
|
|
func (s *ptySession) ResizePTY(cols, rows uint16) error {
|
|
s.mu.Lock()
|
|
ptmx := s.ptmx
|
|
s.mu.Unlock()
|
|
if ptmx == nil {
|
|
return nil // pipe mode or not started
|
|
}
|
|
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
|
|
}
|
|
|
|
// close terminates the session and releases all resources.
|
|
// Safe to call multiple times.
|
|
func (s *ptySession) close() {
|
|
s.mu.Lock()
|
|
if s.closing {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.closing = true
|
|
pid := s.pid
|
|
ptmx := s.ptmx
|
|
stdin := s.stdin
|
|
s.mu.Unlock()
|
|
|
|
if pid != 0 {
|
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
|
}
|
|
if ptmx != nil {
|
|
_ = ptmx.Close()
|
|
} else if stdin != nil {
|
|
_ = stdin.Close()
|
|
}
|
|
|
|
// Detach any active WS output pipe so pump goroutines unblock.
|
|
s.outMu.Lock()
|
|
stdoutW := s.stdoutW
|
|
stderrW := s.stderrW
|
|
s.stdoutW = nil
|
|
s.stderrW = nil
|
|
s.outMu.Unlock()
|
|
if stdoutW != nil {
|
|
_ = stdoutW.Close()
|
|
}
|
|
if stderrW != nil {
|
|
_ = stderrW.Close()
|
|
}
|
|
}
|
|
|
|
// CreatePTYSession creates a new PTY session and stores it in the map.
|
|
func (c *Controller) CreatePTYSession(id, cwd, command string) (PTYSession, error) {
|
|
resolvedCwd, err := pathutil.ExpandPath(cwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error resolving PTY session work directory: %w", err)
|
|
}
|
|
if resolvedCwd != "" {
|
|
err := os.MkdirAll(resolvedCwd, os.ModePerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating PTY session work directory: %w", err)
|
|
}
|
|
}
|
|
s := newPTYSession(id, resolvedCwd, command)
|
|
c.ptySessionMap.Store(id, s)
|
|
log.Info("created pty session %s", id)
|
|
return s, nil
|
|
}
|
|
|
|
// getPTYSession looks up a PTY session by ID. Returns nil if not found.
|
|
// For internal use only; outside callers should use GetPTYSession.
|
|
func (c *Controller) getPTYSession(id string) *ptySession {
|
|
if v, ok := c.ptySessionMap.Load(id); ok {
|
|
if s, ok := v.(*ptySession); ok {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPTYSession looks up a PTY session by ID. Returns nil if not found.
|
|
func (c *Controller) GetPTYSession(id string) PTYSession {
|
|
s := c.getPTYSession(id)
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// DeletePTYSession terminates and removes a PTY session.
|
|
// Returns ErrContextNotFound if the session does not exist.
|
|
func (c *Controller) DeletePTYSession(id string) error {
|
|
s := c.getPTYSession(id)
|
|
if s == nil {
|
|
return ErrContextNotFound
|
|
}
|
|
s.close()
|
|
c.ptySessionMap.Delete(id)
|
|
log.Info("deleted pty session %s", id)
|
|
return nil
|
|
}
|
|
|
|
// GetPTYSessionStatus returns status information for a PTY session.
|
|
func (c *Controller) GetPTYSessionStatus(id string) (running bool, outputOffset int64, err error) {
|
|
s := c.getPTYSession(id)
|
|
if s == nil {
|
|
return false, 0, ErrContextNotFound
|
|
}
|
|
return s.IsRunning(), s.replay.Total(), nil
|
|
}
|