Simplify session lifecycle and MCP cleanup
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user