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:
1 parent
114c6fc530
commit
10ffa393ca
3 files changed
+57
-10
No files matched your search
+5
-1
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user