diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 2334ec7..fae8377 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -38,7 +38,11 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 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) } } diff --git a/internal/agent/session.go b/internal/agent/session.go index 5b9d07e..bc12ec0 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -54,6 +54,10 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite ctx, cancel := context.WithCancel(parent) defer cancel() 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. go func() { diff --git a/internal/agent/terminal.go b/internal/agent/terminal.go index 5a6c46e..97dc39e 100644 --- a/internal/agent/terminal.go +++ b/internal/agent/terminal.go @@ -4,14 +4,38 @@ import ( "fmt" "os" "os/exec" + "sync" + "syscall" + "time" "github.com/creack/pty" ) // PTY is a pseudo-terminal running an interactive login shell. type PTY struct { - cmd *exec.Cmd - f *os.File + cmd *exec.Cmd + 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 @@ -56,7 +80,10 @@ func StartPTY(user string) (*PTY, error) { if err != nil { 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 { @@ -78,12 +105,24 @@ func (p *PTY) Resize(cols, rows uint16) { _ = 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 { - err := p.f.Close() - if p.cmd.Process != nil { - _ = p.cmd.Process.Kill() - } - _ = p.cmd.Wait() + var err error + p.once.Do(func() { + // Closing the master hangs up the session; the signal to the + // shell's process group makes sure it hears, through su as well. + 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 }