Files
multica/server/internal/daemon/terminal/spawner_unix.go
Jiayuan Zhang 281f1073b5 fix(daemon/terminal): address Phase 1 review feedback (MUL-2295)
Wires in the four fixes Emacs flagged on the Phase 1 review:

1. Lifecycle: split stop/done with a WaitGroup. readLoop and idleLoop
   exit via <-stop; waitLoop is the finalizer that waits on the WG
   before closing output/done. Eliminates the "send on closed channel"
   race when the output buffer is saturated. Adds a regression test
   that fills output, calls Close, and verifies Done converges + ExitC
   fires before output closes (the doc contract).

2. Errors: Manager.Open wraps spawner errors with double-%w so
   errors.Is matches both ErrSpawnFailed and ErrUnsupportedOS. Adds a
   test with a fake spawner that returns ErrUnsupportedOS.

3. Close path on unix: SIGHUP to the process group, 250ms grace,
   SIGKILL, then close fd — comment now matches behavior. Skips the
   signal+sleep work entirely when the child already exited naturally.
   Manager.Close fans out per-session Close in parallel so the grace
   period doesn't multiply by session count.

4. IdleTimeout semantics: removes the NewManager default that
   silently rewrote 0 to 60min. Zero/negative now disables, per the
   doc comment. Added DefaultIdleTimeout for daemon wiring to opt in
   explicitly.

Verified: go test, go test -race, GOOS=windows go test -c.
Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 16:48:11 +08:00

103 lines
3.0 KiB
Go

//go:build !windows
package terminal
import (
"fmt"
"os"
"os/exec"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/creack/pty"
)
// closeGracePeriod is the window between SIGHUP and SIGKILL during a
// Close. Long enough for interactive shells to run trap handlers and
// flush state; short enough that closing a tab feels instant.
const closeGracePeriod = 250 * time.Millisecond
// realSpawner forks the shell on a PTY using creack/pty. Linux/macOS
// only; Windows reaches the stub in spawner_windows.go and returns
// ErrUnsupportedOS.
type realSpawner struct{}
func (realSpawner) Start(req SpawnRequest) (PTY, error) {
cmd := exec.Command(req.Shell, req.Args...)
cmd.Dir = req.Cwd
// Inherit the daemon's PATH so users get whatever CLIs are installed
// in the daemon's environment (claude, codex, multica, etc.); merge
// in the per-session vars built by buildEnv.
env := os.Environ()
env = append(env, req.Env...)
cmd.Env = env
size := &pty.Winsize{Cols: req.Cols, Rows: req.Rows}
f, err := pty.StartWithSize(cmd, size)
if err != nil {
return nil, fmt.Errorf("pty.StartWithSize: %w", err)
}
return &unixPTY{cmd: cmd, file: f}, nil
}
type unixPTY struct {
cmd *exec.Cmd
file *os.File
exited atomic.Bool
closeOnce sync.Once
closeErr error
}
func (p *unixPTY) Read(b []byte) (int, error) { return p.file.Read(b) }
func (p *unixPTY) Write(b []byte) (int, error) { return p.file.Write(b) }
func (p *unixPTY) Resize(cols, rows uint16) error {
return pty.Setsize(p.file, &pty.Winsize{Cols: cols, Rows: rows})
}
func (p *unixPTY) Wait() (int, error) {
err := p.cmd.Wait()
p.exited.Store(true)
if p.cmd.ProcessState != nil {
return p.cmd.ProcessState.ExitCode(), err
}
return -1, err
}
// Close terminates the child shell and releases the PTY master fd.
// Closing a tab is a hangup, not an interrupt — so the signal path is
// SIGHUP → brief grace → SIGKILL → file.Close, in that order:
//
// - SIGHUP gives interactive shells a chance to run trap handlers,
// write history, etc. before the fd disappears.
// - The grace window is bounded; anything slower than that is stuck.
// - SIGKILL is the cliff for shells that ignore HUP.
// - file.Close releases the master fd last so the slave side keeps
// working during cleanup.
//
// Signals are sent to the negated pid so they hit the whole process
// group. creack/pty starts the child as a session leader (Setsid), so
// pid == pgid and any descendants the user spawned in the shell are
// caught by the same kill.
//
// If the child already exited naturally (Wait returned), all signal
// work is skipped — we only close the fd. That avoids a pointless
// 250ms sleep in the natural-exit teardown path.
func (p *unixPTY) Close() error {
p.closeOnce.Do(func() {
if p.cmd.Process != nil && !p.exited.Load() {
pid := p.cmd.Process.Pid
_ = syscall.Kill(-pid, syscall.SIGHUP)
time.Sleep(closeGracePeriod)
if !p.exited.Load() {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}
p.closeErr = p.file.Close()
})
return p.closeErr
}