mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* feat(execution): add a stdin-only write for interactive processes * feat(tui): attach overlay for interactive terminal sessions * feat(tui): add /attach for interactive terminal sessions * feat(tools): point the model at tty sessions for interactive prompts * feat(tui): open the terminal automatically for tty exec sessions * feat(tools): tell the model to keep polling while the user types * feat(tui): give attached terminal sessions the full viewport * feat(tools): route sudo through an escalated tty session * fix(tui): route the auto-attach tick to its run * fix(tui): defer auto-attach while another view owns the screen * fix(tui): keep bracketed-paste framing for attached sessions * fix(tools): let tty sessions run interactive commands * fix(tui): capture the auto-attach baseline before the exec starts
397 lines
13 KiB
Go
397 lines
13 KiB
Go
package execution
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func processManagerRequest(root string, command *exec.Cmd) Request {
|
|
return Request{
|
|
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
|
Command: Command{Name: command.Path, Args: command.Args[1:]},
|
|
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
|
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test command uses a POSIX shell")
|
|
}
|
|
root := t.TempDir()
|
|
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
|
|
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
|
|
request := Request{
|
|
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
|
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
|
|
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
|
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
|
}
|
|
started, err := manager.Start(context.Background(), ProcessStart{
|
|
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
|
|
}, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if started.Exited || started.ProcessID < 1000 {
|
|
t.Fatalf("initial result = %#v, want retained process", started)
|
|
}
|
|
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
|
|
if err != nil {
|
|
t.Fatalf("Continue: %v", err)
|
|
}
|
|
if !continued.Exited || continued.ProcessID != started.ProcessID {
|
|
t.Fatalf("continued result = %#v, want same completed process", continued)
|
|
}
|
|
combined := started.Output + continued.Output
|
|
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
|
|
t.Fatalf("output was lost or duplicated: %q", combined)
|
|
}
|
|
if manager.Len() != 0 {
|
|
t.Fatalf("completed process still retained: %d", manager.Len())
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test command uses a POSIX shell")
|
|
}
|
|
root := t.TempDir()
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
|
request := Request{
|
|
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
|
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
|
|
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
|
}
|
|
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
|
|
if err != nil {
|
|
t.Fatalf("Continue interrupt: %v", err)
|
|
}
|
|
if !stopped.Exited || !stopped.Interrupted {
|
|
t.Fatalf("interrupt result = %#v", stopped)
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerWriteInputUnknownProcess(t *testing.T) {
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
if err := manager.WriteInput(4242, []byte("x")); !errors.Is(err, ErrProcessNotFound) {
|
|
t.Fatalf("WriteInput unknown id = %v, want ErrProcessNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerWriteInputRejectsPipeProcess(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test command uses a POSIX shell")
|
|
}
|
|
root := t.TempDir()
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
|
started, err := manager.Start(context.Background(), ProcessStart{
|
|
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
|
}, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
defer manager.Stop(started.ProcessID)
|
|
if started.TTY {
|
|
t.Fatal("pipe process reported a TTY")
|
|
}
|
|
if err := manager.WriteInput(started.ProcessID, []byte("x")); !errors.Is(err, ErrProcessStdinDisabled) {
|
|
t.Fatalf("WriteInput pipe process = %v, want ErrProcessStdinDisabled", err)
|
|
}
|
|
if err := manager.WriteInput(started.ProcessID, nil); err != nil {
|
|
t.Fatalf("WriteInput empty data = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerWriteInputDoesNotDrainPendingOutput(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("PTY sessions are only available on Linux")
|
|
}
|
|
root := t.TempDir()
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
command := exec.CommandContext(context.Background(), "cat")
|
|
started, err := manager.Start(context.Background(), ProcessStart{
|
|
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
|
CommandText: "cat", TTY: true,
|
|
}, 50*time.Millisecond)
|
|
if err != nil {
|
|
t.Skipf("PTY transport unavailable: %v", err)
|
|
}
|
|
if !started.TTY {
|
|
t.Skip("PTY transport fell back to pipes")
|
|
}
|
|
defer manager.Stop(started.ProcessID)
|
|
|
|
if err := manager.WriteInput(started.ProcessID, []byte("hello\r")); err != nil {
|
|
t.Fatalf("WriteInput: %v", err)
|
|
}
|
|
// The PTY echoes input back into the output stream, so the rolling recent
|
|
// tail shows what was typed without anything being drained.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
snapshot, ok := manager.Snapshot(started.ProcessID)
|
|
if ok && strings.Contains(snapshot.RecentOutput, "hello") {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("recent output never echoed the input: %q", snapshot.RecentOutput)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
// WriteInput must not have consumed the pending buffer: a write_stdin-style
|
|
// Continue still collects the echoed bytes.
|
|
continued, err := manager.Continue(context.Background(), ProcessContinue{
|
|
ProcessID: started.ProcessID, Wait: 200 * time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Continue: %v", err)
|
|
}
|
|
if !strings.Contains(continued.Output, "hello") {
|
|
t.Fatalf("WriteInput drained the pending output: continued output = %q", continued.Output)
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerResizeInputUnknownProcess(t *testing.T) {
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
if err := manager.ResizeInput(4242, 100, 40); !errors.Is(err, ErrProcessNotFound) {
|
|
t.Fatalf("ResizeInput unknown id = %v, want ErrProcessNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerResizeInputUpdatesWindowSize(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("PTY sessions are only available on Linux")
|
|
}
|
|
root := t.TempDir()
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
command := exec.CommandContext(context.Background(), "/bin/sh", "-c", "sleep 0.3; stty size")
|
|
started, err := manager.Start(context.Background(), ProcessStart{
|
|
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
|
CommandText: "stty size", TTY: true,
|
|
}, time.Millisecond)
|
|
if err != nil {
|
|
t.Skipf("PTY transport unavailable: %v", err)
|
|
}
|
|
if !started.TTY {
|
|
t.Skip("PTY transport fell back to pipes")
|
|
}
|
|
defer manager.Stop(started.ProcessID)
|
|
|
|
if err := manager.ResizeInput(started.ProcessID, 100, 40); err != nil {
|
|
t.Fatalf("ResizeInput: %v", err)
|
|
}
|
|
continued, err := manager.Continue(context.Background(), ProcessContinue{
|
|
ProcessID: started.ProcessID, Wait: 2 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Continue: %v", err)
|
|
}
|
|
if combined := started.Output + continued.Output; !strings.Contains(combined, "40 100") {
|
|
t.Fatalf("stty size output = %q, want %q", combined, "40 100")
|
|
}
|
|
}
|
|
|
|
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
|
|
reaped := make(chan struct{})
|
|
close(reaped)
|
|
called := false
|
|
process := &managedProcess{
|
|
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
|
|
reaped: reaped,
|
|
kill: func(int) error { called = true; return nil },
|
|
}
|
|
|
|
process.terminate()
|
|
if called {
|
|
t.Fatal("terminate signaled an already-reaped process")
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test command uses a POSIX shell")
|
|
}
|
|
root := t.TempDir()
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
result, err := manager.Start(ctx, ProcessStart{
|
|
Prepared: PreparedCommand{Command: command},
|
|
Request: processManagerRequest(root, command),
|
|
}, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if !result.Exited || !result.Interrupted {
|
|
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
|
|
}
|
|
}
|
|
|
|
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test command uses a POSIX shell")
|
|
}
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "created.txt")
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
|
|
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
|
|
return nil, false, nil, err
|
|
}
|
|
return startProcessTransport(command, output, tty)
|
|
}
|
|
command := exec.Command("/bin/sh", "-c", "true")
|
|
|
|
result, err := manager.Start(context.Background(), ProcessStart{
|
|
Prepared: PreparedCommand{Command: command},
|
|
Request: processManagerRequest(root, command),
|
|
}, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
|
|
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
|
|
}
|
|
}
|
|
|
|
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
|
|
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
|
|
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
|
|
}
|
|
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
|
|
t.Fatalf("initial short wait = %v, want caller value", got)
|
|
}
|
|
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
|
|
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
|
|
}
|
|
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
|
|
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
|
|
}
|
|
}
|
|
|
|
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
|
|
buffer := newProcessOutputBuffer()
|
|
chunk := []byte(strings.Repeat("x", 1024))
|
|
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
|
|
if _, err := buffer.Write(chunk); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
}
|
|
|
|
buffer.mu.Lock()
|
|
dataLen := len(buffer.data)
|
|
buffer.mu.Unlock()
|
|
if dataLen > maxPendingOutputBytes {
|
|
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
|
|
}
|
|
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
|
|
t.Fatal("drained output should retain the newest bytes")
|
|
}
|
|
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
|
|
t.Fatal("truncation state was not preserved and consumed exactly once")
|
|
}
|
|
}
|
|
|
|
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
|
|
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
|
|
stop := make(chan struct{})
|
|
var writers sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
writers.Add(1)
|
|
go func() {
|
|
defer writers.Done()
|
|
chunk := []byte(strings.Repeat("x", 256))
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
_, _ = process.output.Write(chunk)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
t.Cleanup(func() { close(stop); writers.Wait() })
|
|
|
|
wait := 200 * time.Millisecond
|
|
started := time.Now()
|
|
_, _ = process.collect(context.Background(), wait)
|
|
if elapsed := time.Since(started); elapsed > 5*wait {
|
|
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
|
|
}
|
|
}
|
|
|
|
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
|
|
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
|
|
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
|
|
go func() {
|
|
for i := 0; i < 5; i++ {
|
|
_, _ = process.output.Write(chunk)
|
|
for {
|
|
process.output.mu.Lock()
|
|
drained := len(process.output.data) == 0
|
|
process.output.mu.Unlock()
|
|
if drained {
|
|
break
|
|
}
|
|
runtime.Gosched()
|
|
}
|
|
}
|
|
close(process.done)
|
|
}()
|
|
|
|
output, truncated := process.collect(context.Background(), time.Second)
|
|
if len(output) > maxPendingOutputBytes {
|
|
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
|
|
}
|
|
if !truncated {
|
|
t.Fatal("cumulative output cap must report truncation")
|
|
}
|
|
}
|
|
|
|
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
|
|
manager := NewProcessManager(ProcessManagerOptions{})
|
|
for id := 1000; id < 1012; id++ {
|
|
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
|
|
}
|
|
target := manager.processes[1000]
|
|
stop := make(chan struct{})
|
|
var writer sync.WaitGroup
|
|
writer.Add(1)
|
|
go func() {
|
|
defer writer.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
target.touch()
|
|
}
|
|
}
|
|
}()
|
|
for i := 0; i < 2000; i++ {
|
|
manager.mu.Lock()
|
|
_ = manager.processToPruneLocked()
|
|
manager.mu.Unlock()
|
|
}
|
|
close(stop)
|
|
writer.Wait()
|
|
}
|