Files
byre/internal/terminal/terminal.go
T

508 lines
11 KiB
Go

package terminal
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/creack/pty"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/ids"
)
const (
backlogSize = 256 << 10
clientBuf = 256
MaxSize = 1 << 15
promptMark = "\x1b]133;D;"
lineMark = "\x1b]7701;"
carryMax = 64 << 10
keepSize = 256 << 10
Quiet = 500 * time.Millisecond
MaxWait = 10 * time.Second
)
var lineMarks = [][]byte{[]byte(promptMark), []byte(lineMark)}
const zshenv = `if [[ -n $BYRE_ZDOTDIR ]]; then
ZDOTDIR=$BYRE_ZDOTDIR
else
unset ZDOTDIR
fi
unset BYRE_ZDOTDIR
[[ -f ${ZDOTDIR:-$HOME}/.zshenv ]] && source ${ZDOTDIR:-$HOME}/.zshenv
if [[ -o interactive ]]; then
unsetopt PROMPT_SP
_byre_precmd() { print -n "\e]133;D;$?\a" }
precmd_functions+=(_byre_precmd)
_byre_line() { local line=$PREBUFFER$BUFFER; print -rn -- $'\e]7701;L'"${line//[[:cntrl:]]/ }"$'\a' }
_byre_line_end() { print -rn -- $'\e]7701;E\a' }
zmodload zsh/zle
autoload -Uz add-zle-hook-widget
add-zle-hook-widget line-init _byre_line
add-zle-hook-widget line-pre-redraw _byre_line
add-zle-hook-widget line-finish _byre_line_end
fi
`
type Terminals struct {
zdotdir string
changed func(taskID string)
prompted func(taskID string)
mu sync.Mutex
byTask map[string][]*Terminal
}
type Terminal struct {
ID string
Name string
ptmx *os.File
cmd *exec.Cmd
Prompts bool
prompted func()
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
started time.Time
lastOutput time.Time
wake *wake
carry []byte
line string
atPrompt bool
}
type wake struct {
fn func(exit string, out []byte)
out []byte
}
func New(zdotdir string, changed, prompted func(taskID string)) (*Terminals, error) {
err := os.MkdirAll(zdotdir, 0o755)
if err != nil {
return nil, err
}
err = os.WriteFile(filepath.Join(zdotdir, ".zshenv"), []byte(zshenv), 0o644)
if err != nil {
return nil, err
}
return &Terminals{zdotdir: zdotdir, changed: changed, prompted: prompted, byTask: map[string][]*Terminal{}}, nil
}
func (ts *Terminals) List(taskID string) []*Terminal {
ts.mu.Lock()
defer ts.mu.Unlock()
return slices.Clone(ts.byTask[taskID])
}
func (ts *Terminals) All() map[string][]*Terminal {
ts.mu.Lock()
defer ts.mu.Unlock()
out := map[string][]*Terminal{}
for taskID, terms := range ts.byTask {
out[taskID] = slices.Clone(terms)
}
return out
}
func (ts *Terminals) Get(taskID, id string) *Terminal {
ts.mu.Lock()
defer ts.mu.Unlock()
for _, term := range ts.byTask[taskID] {
if term.ID == id {
return term
}
}
return nil
}
func (ts *Terminals) First(taskID string) *Terminal {
ts.mu.Lock()
defer ts.mu.Unlock()
if len(ts.byTask[taskID]) == 0 {
return nil
}
return ts.byTask[taskID][0]
}
func (ts *Terminals) Start(t api.Task, name, command string, size api.TerminalSize) (*Terminal, error) {
term, err := ts.start(t, name, size)
if err != nil {
return nil, err
}
ts.changed(t.Id)
if command != "" {
go term.typeWhenReady(command + "\r")
}
return term, nil
}
func (ts *Terminals) start(t api.Task, name string, size api.TerminalSize) (*Terminal, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
shell := os.Getenv("SHELL")
if shell == "" {
return nil, errors.New("SHELL is not set in byre's environment")
}
cmd := exec.Command(shell, "-l")
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
prompts := filepath.Base(shell) == "zsh"
if prompts {
cmd.Env = append(cmd.Env, "BYRE_ZDOTDIR="+os.Getenv("ZDOTDIR"), "ZDOTDIR="+ts.zdotdir)
}
ptmx, err := pty.StartWithSize(cmd, winsize(size))
if err != nil {
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term := &Terminal{ID: ids.New(ids.Terminal), Name: name, ptmx: ptmx, cmd: cmd, Prompts: prompts, prompted: func() { ts.prompted(t.Id) }, clients: map[chan []byte]bool{}, started: time.Now()}
ts.byTask[t.Id] = append(ts.byTask[t.Id], term)
slog.Info("terminal started", "task", t.Id, "terminal", term.ID, "name", name, "shell", shell, "pid", cmd.Process.Pid)
go func() {
term.pump()
if ts.remove(t.Id, term) {
ts.changed(t.Id)
}
slog.Info("terminal ended", "task", t.Id, "terminal", term.ID, "exit", term.exit)
}()
return term, nil
}
func (ts *Terminals) remove(taskID string, term *Terminal) bool {
ts.mu.Lock()
defer ts.mu.Unlock()
i := slices.Index(ts.byTask[taskID], term)
if i < 0 {
return false
}
ts.byTask[taskID] = slices.Delete(ts.byTask[taskID], i, i+1)
if len(ts.byTask[taskID]) == 0 {
delete(ts.byTask, taskID)
}
return true
}
func (ts *Terminals) Kill(taskID, id string) bool {
term := ts.Get(taskID, id)
if term == nil || !ts.remove(taskID, term) {
return false
}
term.kill()
ts.changed(taskID)
return true
}
func (ts *Terminals) Close(taskID string) {
ts.mu.Lock()
terms := ts.byTask[taskID]
delete(ts.byTask, taskID)
ts.mu.Unlock()
for _, term := range terms {
term.kill()
}
}
func (ts *Terminals) CloseAll() {
ts.mu.Lock()
all := ts.byTask
ts.byTask = map[string][]*Terminal{}
ts.mu.Unlock()
for _, terms := range all {
for _, term := range terms {
term.kill()
}
}
}
func winsize(size api.TerminalSize) *pty.Winsize {
return &pty.Winsize{Cols: uint16(size.Cols), Rows: uint16(size.Rows)}
}
func ValidSize(size api.TerminalSize) bool {
return size.Cols >= 1 && size.Rows >= 1 && size.Cols <= MaxSize && size.Rows <= MaxSize
}
func (term *Terminal) pump() {
buf := make([]byte, 32<<10)
for {
n, err := term.ptmx.Read(buf)
if n > 0 {
term.output(bytes.Clone(buf[:n]))
}
if err != nil {
break
}
}
err := term.cmd.Wait()
term.ptmx.Close()
exit := fmt.Sprint(err)
if term.cmd.ProcessState != nil {
exit = term.cmd.ProcessState.String()
}
term.mu.Lock()
defer term.mu.Unlock()
term.exit = exit
term.exited = true
for c := range term.clients {
close(c)
}
term.clients = nil
}
func (term *Terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.lastOutput = time.Now()
if term.wake != nil {
term.checkWake(b)
}
if term.Prompts {
term.trackLine(b)
}
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*backlogSize {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-backlogSize:])
}
for c := range term.clients {
select {
case c <- b:
default:
delete(term.clients, c)
close(c)
}
}
}
func (term *Terminal) checkWake(b []byte) {
w := term.wake
from := max(0, len(w.out)-len(promptMark)-8)
w.out = append(w.out, b...)
i := bytes.Index(w.out[from:], []byte(promptMark))
if i < 0 {
if len(w.out) > 2*backlogSize {
w.out = bytes.Clone(w.out[len(w.out)-backlogSize:])
}
return
}
start := from + i
end := bytes.IndexByte(w.out[start:], '\a')
if end < 0 {
return
}
term.wake = nil
go w.fn(string(w.out[start+len(promptMark):start+end]), w.out[:start])
}
func (term *Terminal) trackLine(b []byte) {
s := append(term.carry, b...)
term.carry = nil
for {
i := bytes.Index(s, []byte("\x1b]"))
if i < 0 {
if len(s) > 0 && s[len(s)-1] == 0x1b {
term.carry = []byte{0x1b}
}
return
}
rest := s[i:]
var prefix []byte
for _, m := range lineMarks {
if bytes.HasPrefix(rest, m) {
prefix = m
break
}
if len(rest) < len(m) && bytes.HasPrefix(m, rest) {
term.carry = bytes.Clone(rest)
return
}
}
if prefix == nil {
s = rest[2:]
continue
}
end := bytes.IndexByte(rest, '\a')
if end < 0 {
if len(rest) < carryMax {
term.carry = bytes.Clone(rest)
}
return
}
body := string(rest[len(prefix):end])
switch {
case string(prefix) == promptMark:
term.atPrompt, term.line = true, ""
go term.prompted()
case body == "E":
term.atPrompt, term.line = false, ""
case strings.HasPrefix(body, "L"):
term.atPrompt, term.line = true, body[1:]
}
s = rest[end+1:]
}
}
func (term *Terminal) InputLine() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.line, term.atPrompt
}
func (term *Terminal) WakeOnPrompt(fn func(exit string, out []byte), mayRun bool) bool {
term.mu.Lock()
defer term.mu.Unlock()
if term.atPrompt && term.line == "" || !term.atPrompt && !mayRun {
return false
}
term.wake = &wake{fn: fn}
return true
}
func (term *Terminal) CancelWake() {
term.mu.Lock()
defer term.mu.Unlock()
term.wake = nil
}
func (term *Terminal) Backlog() []byte {
term.mu.Lock()
defer term.mu.Unlock()
return bytes.Clone(term.backlog[max(0, len(term.backlog)-backlogSize):])
}
func (term *Terminal) Subscribe() ([]byte, chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.exited {
return nil, nil
}
c := make(chan []byte, clientBuf)
term.clients[c] = true
return bytes.Clone(term.backlog[max(0, len(term.backlog)-backlogSize):]), c
}
func (term *Terminal) Unsubscribe(c chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.clients[c] {
delete(term.clients, c)
close(c)
}
}
func (term *Terminal) ExitStatus() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.exit, term.exited
}
func (term *Terminal) Activity() (started, lastOutput time.Time) {
term.mu.Lock()
defer term.mu.Unlock()
return term.started, term.lastOutput
}
func (term *Terminal) Started() time.Time {
term.mu.Lock()
defer term.mu.Unlock()
return term.started
}
func (term *Terminal) typeWhenReady(text string) {
backlog, out := term.Subscribe()
if out == nil {
return
}
_, err := term.AwaitPrompt(context.Background(), out, backlog)
term.Unsubscribe(out)
if err != nil {
return
}
err = term.Write([]byte(text))
if err != nil {
slog.Warn("type saved command", "terminal", term.ID, "err", err)
}
}
func (term *Terminal) Settling() bool {
return term.promptWait() > 0
}
func (term *Terminal) promptWait() time.Duration {
started, last := term.Activity()
wait := time.Until(started.Add(MaxWait))
if !last.IsZero() {
wait = min(wait, time.Until(last.Add(Quiet)))
}
return wait
}
func (term *Terminal) AwaitPrompt(ctx context.Context, out chan []byte, got []byte) ([]byte, error) {
for {
wait := term.promptWait()
if wait <= 0 {
return got, nil
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
case b, open := <-out:
timer.Stop()
if !open {
return got, nil
}
got = KeepTail(append(got, b...))
}
}
}
func KeepTail(b []byte) []byte {
if len(b) > 2*keepSize {
return bytes.Clone(b[len(b)-keepSize:])
}
return b
}
func (term *Terminal) Write(b []byte) error {
_, err := term.ptmx.Write(b)
return err
}
func (term *Terminal) Resize(size api.TerminalSize) {
err := pty.Setsize(term.ptmx, winsize(size))
if err != nil {
slog.Debug("terminal resize", "err", err)
}
}
func (term *Terminal) kill() {
err := syscall.Kill(-term.cmd.Process.Pid, syscall.SIGHUP)
if err != nil && !errors.Is(err, syscall.ESRCH) {
slog.Warn("hang up terminal", "pid", term.cmd.Process.Pid, "err", err)
}
term.ptmx.Close()
}