Simplify session lifecycle and MCP cleanup

This commit is contained in:
2026-05-14 20:51:37 +01:00
parent 27361f79c4
commit cc4bf9e904
16 changed files with 439 additions and 255 deletions

View File

@@ -18,6 +18,8 @@ import (
"github.com/hjbdev/patterm/internal/vt"
)
const childStopTimeout = 2 * time.Second
// Session is the in-memory state for the running patterm process.
// In SPEC §4 terms each top-level tab is a session; v1 ships with a
// single implicit session and reserves room to grow.
@@ -117,6 +119,10 @@ type SpawnSpec struct {
ParentID string
PresetRef string
Identity string // pre-minted; otherwise the constructor mints one for agents
// CleanupPaths are owned runtime files/dirs removed when the child exits
// or is closed. They must be attached before the PTY starts so a
// fast-exiting child cannot outrun cleanup registration.
CleanupPaths []string
}
// Spawn creates a new entry and starts its PTY. For Kind = command the
@@ -144,7 +150,12 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
if spec.Identity != "" {
c.Identity = spec.Identity
}
if err := c.startPTY(cols, rows); err != nil {
for _, path := range spec.CleanupPaths {
c.AddCleanupPath(path)
}
runID, err := c.startPTY(cols, rows)
if err != nil {
c.cleanupOwnedPaths()
return nil, err
}
@@ -154,33 +165,11 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
s.mu.Unlock()
s.emitSpawn(c)
go s.pumpChild(c)
go s.reapChild(c)
go s.pumpChild(c, runID)
go s.reapChild(c, runID)
return c, nil
}
// AddCommandEntry registers a command entry without starting it. Used
// by spawn_process(kind: command) when SPEC §7 needs the entry to exist
// in `stopped` state first (we always start it after; the indirection
// is here so future versions can support deferred starts).
func (s *Session) AddCommandEntry(spec SpawnSpec) *Child {
s.mu.Lock()
id := s.mintUniqueIDLocked()
s.nameSeq[spec.Kind]++
if spec.Name == "" {
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
}
if spec.Env == nil {
spec.Env = s.ChildEnv()
}
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
s.children[id] = c
s.order = append(s.order, id)
s.mu.Unlock()
s.emitSpawn(c)
return c
}
// Start (re)attaches a PTY to an entry that is currently stopped or
// exited. Errors if the entry is already live.
func (s *Session) Start(id string, cols, rows uint16) error {
@@ -191,11 +180,12 @@ func (s *Session) Start(id string, cols, rows uint16) error {
if c.IsLive() {
return nil // SPEC §7 start_process is a no-op on a running entry
}
if err := c.startPTY(cols, rows); err != nil {
runID, err := c.startPTY(cols, rows)
if err != nil {
return err
}
go s.pumpChild(c)
go s.reapChild(c)
go s.pumpChild(c, runID)
go s.reapChild(c, runID)
return nil
}
@@ -210,32 +200,20 @@ func (s *Session) Restart(id string, sig syscall.Signal, cols, rows uint16) erro
if c.Kind != KindCommand && !c.IsLive() {
return fmt.Errorf("restart: %s entries can only be restarted while live", c.Kind)
}
// Only live entries can own runtime MCP config paths today. Keep the
// reaper from cleaning those paths while restart swaps the PTY.
c.restarting.Store(true)
defer c.restarting.Store(false)
if c.IsLive() {
if sig == 0 {
sig = syscall.SIGTERM
}
_ = c.signal(sig)
// Wait briefly for the reaper to mark exited. We don't need
// strict synchronization — the reaper will run regardless; we
// just want startPTY to land after teardown.
deadline := time.Now().Add(2 * time.Second)
for c.IsLive() && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if c.IsLive() {
// Force.
_ = c.signal(syscall.SIGKILL)
for c.IsLive() {
time.Sleep(20 * time.Millisecond)
}
}
terminateAndWait(c, sig, childStopTimeout)
}
c.teardownPTY()
if err := c.startPTY(cols, rows); err != nil {
runID, err := c.startPTY(cols, rows)
if err != nil {
return err
}
go s.pumpChild(c)
go s.reapChild(c)
go s.pumpChild(c, runID)
go s.reapChild(c, runID)
return nil
}
@@ -247,22 +225,10 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
return fmt.Errorf("no such process %q", id)
}
if c.IsLive() {
if sig == 0 {
sig = syscall.SIGTERM
}
_ = c.signal(sig)
deadline := time.Now().Add(2 * time.Second)
for c.IsLive() && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if c.IsLive() {
_ = c.signal(syscall.SIGKILL)
for c.IsLive() {
time.Sleep(20 * time.Millisecond)
}
}
terminateAndWait(c, sig, childStopTimeout)
}
c.teardownPTY()
c.cleanupOwnedPaths()
s.mu.Lock()
delete(s.children, id)
for i, oid := range s.order {
@@ -286,15 +252,18 @@ func (s *Session) mintUniqueIDLocked() string {
}
}
func (s *Session) pumpChild(c *Child) {
func (s *Session) pumpChild(c *Child, runID uint64) {
pty := c.ptyForRun(runID)
if pty == nil {
return
}
buf := make([]byte, 64*1024)
for {
pty := c.PTY()
if pty == nil {
return
}
n, err := pty.Read(buf)
if n > 0 {
if !c.isCurrentRun(runID) {
return
}
chunk := make([]byte, n)
copy(chunk, buf[:n])
if em := c.Emulator(); em != nil {
@@ -314,16 +283,22 @@ func (s *Session) pumpChild(c *Child) {
}
}
func (s *Session) reapChild(c *Child) {
pty := c.PTY()
func (s *Session) reapChild(c *Child, runID uint64) {
pty := c.ptyForRun(runID)
if pty == nil {
return
}
err := pty.Wait()
if !c.isCurrentRun(runID) || c.restarting.Load() {
return
}
c.markExited(err)
logf("child %s exited (err=%v)", c.ID, err)
s.emitExit(c)
s.killDescendantsOf(c.ID)
if !c.restarting.Load() {
c.cleanupOwnedPaths()
}
}
// killDescendantsOf terminates every still-live direct child of
@@ -352,24 +327,49 @@ func (s *Session) killDescendantsOf(parentID string) {
for _, c := range live {
_ = c.signal(syscall.SIGTERM)
}
deadline := time.Now().Add(2 * time.Second)
waitForAllStopped(live, childStopTimeout)
for _, c := range live {
if c.IsLive() {
_ = c.signal(syscall.SIGKILL)
}
}
waitForAllStopped(live, childStopTimeout)
}
func waitForAllStopped(children []*Child, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
anyLive := false
for _, c := range live {
for _, c := range children {
if c.IsLive() {
anyLive = true
break
}
}
if !anyLive {
return
return true
}
time.Sleep(20 * time.Millisecond)
}
for _, c := range live {
if c.IsLive() {
_ = c.signal(syscall.SIGKILL)
}
return false
}
func terminateAndWait(c *Child, sig syscall.Signal, timeout time.Duration) {
if sig == 0 {
sig = syscall.SIGTERM
}
_ = c.signal(sig)
deadline := time.Now().Add(timeout)
for c.IsLive() && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if !c.IsLive() {
return
}
_ = c.signal(syscall.SIGKILL)
deadline = time.Now().Add(timeout)
for c.IsLive() && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
}
@@ -524,6 +524,7 @@ func (s *Session) Shutdown() {
// emitExit as Wait() returns.
for _, c := range cs {
c.teardownPTY()
c.cleanupOwnedPaths()
}
}