fix: a closed terminal hangs its shell up instead of killing it, so the history is saved; idle shells end with their session, and on agent stop

This commit is contained in:
redtea committed 2026-10-14 19:50:50 +03:00
1 parent 114c6fc530
commit 10ffa393ca
3 files changed
+57 -10

No files matched your search

+5 -1
View File
@@ -38,7 +38,11 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
if err := agent.New(cfg).Run(ctx); err != nil { err := agent.New(cfg).Run(ctx)
// Stopping (an update, a restart): hang the terminals up, so their shells
// save their history instead of being killed.
agent.CloseTerminals()
if err != nil {
log.Fatalf("agent: %v", err) log.Fatalf("agent: %v", err)
} }
} }
+4
View File
@@ -54,6 +54,10 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
defer cancel() defer cancel()
defer rw.Close() defer rw.Close()
// A session closed from the panel (a tab reloaded, a window shut) ends
// the stream at once, not on its next output: an idle shell would live
// on until the agent stopped.
go func() { <-ctx.Done(); rw.Close() }()
// Downstream: pull input frames and write them to the stream. // Downstream: pull input frames and write them to the stream.
go func() { go func() {
+48 -9
View File
@@ -4,14 +4,38 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"sync"
"syscall"
"time"
"github.com/creack/pty" "github.com/creack/pty"
) )
// PTY is a pseudo-terminal running an interactive login shell. // PTY is a pseudo-terminal running an interactive login shell.
type PTY struct { type PTY struct {
cmd *exec.Cmd cmd *exec.Cmd
f *os.File f *os.File
exited chan struct{} // closed once the shell has exited
once sync.Once
}
// hangupGrace is how long a shell gets after the hangup to save its history
// and exit before it is killed.
const hangupGrace = 3 * time.Second
// openPTYs are the terminals running now, to close on shutdown.
var openPTYs sync.Map // *PTY -> struct{}
// CloseTerminals hangs up every open terminal and waits for them, so the
// shells save their history before the agent exits.
func CloseTerminals() {
var wg sync.WaitGroup
openPTYs.Range(func(k, _ any) bool {
wg.Add(1)
go func() { defer wg.Done(); k.(*PTY).Close() }()
return true
})
wg.Wait()
} }
// StartPTY launches a login shell attached to a new PTY. With user empty (or // StartPTY launches a login shell attached to a new PTY. With user empty (or
@@ -56,7 +80,10 @@ func StartPTY(user string) (*PTY, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &PTY{cmd: cmd, f: f}, nil p := &PTY{cmd: cmd, f: f, exited: make(chan struct{})}
go func() { _ = cmd.Wait(); close(p.exited) }()
openPTYs.Store(p, struct{}{})
return p, nil
} }
func isDir(p string) bool { func isDir(p string) bool {
@@ -78,12 +105,24 @@ func (p *PTY) Resize(cols, rows uint16) {
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) _ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
} }
// Close terminates the shell and releases the PTY. // Close hangs the terminal up as a closed window would: the shell gets
// SIGHUP, saves its history and exits. One that does not within hangupGrace
// is killed. Safe to call more than once.
func (p *PTY) Close() error { func (p *PTY) Close() error {
err := p.f.Close() var err error
if p.cmd.Process != nil { p.once.Do(func() {
_ = p.cmd.Process.Kill() // Closing the master hangs up the session; the signal to the
} // shell's process group makes sure it hears, through su as well.
_ = p.cmd.Wait() err = p.f.Close()
pid := p.cmd.Process.Pid
_ = syscall.Kill(-pid, syscall.SIGHUP)
select {
case <-p.exited:
case <-time.After(hangupGrace):
_ = syscall.Kill(-pid, syscall.SIGKILL)
<-p.exited
}
openPTYs.Delete(p)
})
return err return err
} }