This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/internal/shell/manager.go
T
tao.chen f5a6ff8b0d feat(shell): PTY resize via HTTP + frontend xterm.onResize hook
The PTY was hardcoded to 80x24 at start, so full-screen programs
(htop, vim, less, tmux, top) drew themselves for 80x24 regardless
of the actual xterm window. Add a path for the frontend to push the
real cols/rows to the backend, which calls pty.Setsize on the pty
file.

Backend:
- internal/shell/manager.go: Resize(workspaceID, cols, rows) added to
  the Manager interface and implemented on LocalManager. Looks up the
  session, type-asserts sess.Stdin.(*os.File), calls pty.Setsize.
  Validates cols/rows in [1, 10000] and returns CodeBadRequest on
  bad input, CodeNotFound when no session.
- internal/service/shell_service.go: ShellService.Resize wrapper that
  checks workspace existence first.
- internal/api/shell_handler.go: resize handler, 204 on success.
- internal/api/router.go: register POST /workspaces/:id/shell/resize.
- internal/model/shell.go: ShellResizeRequest{Cols, Rows}.
- internal/shell/manager_test.go: 3 new tests (valid resize via
  pty.Getsize round-trip, invalid size, missing session).

Frontend:
- web/src/lib/api/process.ts: useShellResize mutation hook.
- web/src/components/terminal/TerminalPanel.tsx: terminal.onResize
  subscription with 100ms debounce; filters 0x0; only fires when
  workspaceId is set; cleanup clears timeout + disposes listener.

E2E: 'stty size' after POST /shell/resize {cols:200, rows:50} returns
'50 200'. 0x40 → 400, missing workspace → 404, valid → 204.

Conversation: 019f360a-5eba-7c81-94d3-e5d58ad3c026
2026-07-06 14:16:09 +08:00

310 lines
7.6 KiB
Go

package shell
import (
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
"codespace/internal/util"
"github.com/creack/pty"
)
const (
outputBufferSize = 4 * 1024
)
// DefaultShellCommand is the default command used to launch a shell.
const DefaultShellCommand = "bash"
// Manager manages interactive shells per workspace.
type Manager interface {
Start(workspaceID string, workspaceRoot string) error
Stop(workspaceID string) error
Restart(workspaceID string, workspaceRoot string) error
Status(workspaceID string) Status
Subscribe(workspaceID string) (Subscription, error)
Stdin(workspaceID string) (io.WriteCloser, error)
ExitStatus(workspaceID string) (ExitInfo, error)
Resize(workspaceID string, cols, rows int) error
}
// LocalManager implements Manager using local OS processes.
type LocalManager struct {
command string
args []string
mu sync.Mutex
sessions map[string]*Session
exitedMu sync.Mutex
exited map[string]struct{}
}
// NewManager creates a LocalManager with the given command and arguments.
// If command is empty, it defaults to "bash".
func NewManager(command string, args []string) *LocalManager {
return &LocalManager{
command: normalizeCommand(command),
args: args,
sessions: make(map[string]*Session),
exited: make(map[string]struct{}),
}
}
func normalizeCommand(command string) string {
if command == "" {
return DefaultShellCommand
}
return command
}
// Start launches a shell for the given workspace.
// Returns CodeConflict if a session is already running.
func (m *LocalManager) Start(workspaceID string, workspaceRoot string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.sessions[workspaceID]; ok {
m.exitedMu.Lock()
_, exited := m.exited[workspaceID]
m.exitedMu.Unlock()
if !exited {
return util.New(util.CodeConflict, "shell already running")
}
}
cmd := exec.Command(m.command, m.args...)
cmd.Dir = workspaceRoot
cmd.Env = append(os.Environ(),
"TERM=xterm-256color",
fmt.Sprintf("HOME=%s", workspaceRoot),
)
ptyF, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80, X: 0, Y: 0})
if err != nil {
return util.Wrap(util.CodeInternal, "failed to start shell on pty", err)
}
sess := &Session{
WorkspaceID: workspaceID,
Root: workspaceRoot,
Cmd: cmd,
Stdin: ptyF,
Subscribers: make(map[Subscription]struct{}),
}
m.sessions[workspaceID] = sess
m.exitedMu.Lock()
delete(m.exited, workspaceID)
m.exitedMu.Unlock()
outputDone := make(chan struct{})
go m.captureOutput(sess, ptyF, outputDone)
go m.waitExit(sess, outputDone)
return nil
}
// captureOutput reads from the PTY master and fans out each chunk to all
// subscribers using non-blocking sends.
func (m *LocalManager) captureOutput(sess *Session, ptyF *os.File, done chan<- struct{}) {
defer close(done)
defer ptyF.Close()
buf := make([]byte, outputBufferSize)
for {
n, err := ptyF.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
sess.mu.Lock()
for sub := range sess.Subscribers {
sub.(*subscription).send(chunk)
}
sess.mu.Unlock()
}
if err != nil {
if err != io.EOF {
// Ignore read errors; the PTY is closing.
}
break
}
}
}
// waitExit waits for the shell to exit, then records the exit status and
// closes every subscriber channel exactly once. The PTY master is closed by
// captureOutput once reads finish.
func (m *LocalManager) waitExit(sess *Session, outputDone <-chan struct{}) {
_ = sess.Cmd.Wait()
<-outputDone
m.exitedMu.Lock()
m.exited[sess.WorkspaceID] = struct{}{}
m.exitedMu.Unlock()
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.Cmd.ProcessState != nil {
sess.Exit.Code = sess.Cmd.ProcessState.ExitCode()
if ws, ok := sess.Cmd.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
sess.Exit.Signal = ws.Signal().String()
}
}
for sub := range sess.Subscribers {
sub.(*subscription).closeChan()
}
}
// Stop kills the shell for the given workspace.
// Returns CodeNotFound if no session exists.
func (m *LocalManager) Stop(workspaceID string) error {
m.mu.Lock()
defer m.mu.Unlock()
sess, ok := m.sessions[workspaceID]
if !ok {
return util.New(util.CodeNotFound, "no running shell for workspace")
}
if sess.Cmd.Process != nil {
_ = sess.Cmd.Process.Kill()
}
delete(m.sessions, workspaceID)
m.exitedMu.Lock()
delete(m.exited, workspaceID)
m.exitedMu.Unlock()
return nil
}
// Restart stops (if running) then starts the shell.
func (m *LocalManager) Restart(workspaceID string, workspaceRoot string) error {
m.mu.Lock()
if sess, ok := m.sessions[workspaceID]; ok {
m.exitedMu.Lock()
_, exited := m.exited[workspaceID]
m.exitedMu.Unlock()
if !exited && sess.Cmd.Process != nil {
_ = sess.Cmd.Process.Kill()
}
delete(m.sessions, workspaceID)
m.exitedMu.Lock()
delete(m.exited, workspaceID)
m.exitedMu.Unlock()
}
m.mu.Unlock()
return m.Start(workspaceID, workspaceRoot)
}
// Resize resizes the PTY for the given workspace.
// Returns CodeNotFound if no session exists, or CodeBadRequest for invalid dimensions.
func (m *LocalManager) Resize(workspaceID string, cols, rows int) error {
m.mu.Lock()
sess, ok := m.sessions[workspaceID]
m.mu.Unlock()
if !ok {
return util.New(util.CodeNotFound, "no running shell for workspace")
}
if cols <= 0 || rows <= 0 || cols > 10000 || rows > 10000 {
return util.New(util.CodeBadRequest, "invalid cols/rows")
}
f, ok := sess.Stdin.(*os.File)
if !ok {
return util.New(util.CodeInternal, "shell stdin is not a pty file")
}
return pty.Setsize(f, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows), X: 0, Y: 0})
}
// Status returns the current shell status for the workspace.
func (m *LocalManager) Status(workspaceID string) Status {
m.mu.Lock()
sess, ok := m.sessions[workspaceID]
m.mu.Unlock()
if !ok {
return Status{WorkspaceID: workspaceID, Running: false}
}
m.exitedMu.Lock()
_, exited := m.exited[workspaceID]
m.exitedMu.Unlock()
if exited {
return Status{WorkspaceID: workspaceID, Running: false}
}
return Status{
WorkspaceID: workspaceID,
Running: true,
PID: sess.Cmd.Process.Pid,
}
}
// Subscribe creates a new output subscription for the workspace.
// Returns CodeNotFound if the workspace has no session.
func (m *LocalManager) Subscribe(workspaceID string) (Subscription, error) {
m.mu.Lock()
sess, ok := m.sessions[workspaceID]
m.mu.Unlock()
if !ok {
return nil, util.New(util.CodeNotFound, "no running shell")
}
sess.mu.Lock()
sub := newSubscription(sess)
sess.Subscribers[sub] = struct{}{}
sess.mu.Unlock()
m.exitedMu.Lock()
_, exited := m.exited[workspaceID]
m.exitedMu.Unlock()
if exited {
sub.closeChan()
}
return sub, nil
}
// Stdin returns the stdin writer for the workspace shell.
func (m *LocalManager) Stdin(workspaceID string) (io.WriteCloser, error) {
return m.stdinOf(workspaceID)
}
// ExitStatus returns the exit status for the workspace shell.
func (m *LocalManager) ExitStatus(workspaceID string) (ExitInfo, error) {
return m.exitOf(workspaceID)
}
// stdinOf returns the session's stdin writer or CodeNotFound.
func (m *LocalManager) stdinOf(workspaceID string) (io.WriteCloser, error) {
m.mu.Lock()
defer m.mu.Unlock()
sess, ok := m.sessions[workspaceID]
if !ok {
return nil, util.New(util.CodeNotFound, "no running shell")
}
return sess.Stdin, nil
}
// exitOf returns the session's recorded exit info or CodeNotFound.
func (m *LocalManager) exitOf(workspaceID string) (ExitInfo, error) {
m.mu.Lock()
sess, ok := m.sessions[workspaceID]
m.mu.Unlock()
if !ok {
return ExitInfo{}, util.New(util.CodeNotFound, "no running shell")
}
sess.mu.Lock()
defer sess.mu.Unlock()
return sess.Exit, nil
}