package main import ( "bytes" "errors" "io" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "testing" "time" "github.com/creack/pty" ) func TestRealPTYProcessRestoresAlternateScreenOnNormalAndDeferredMutationExit(t *testing.T) { root := t.TempDir() binary := filepath.Join(root, "bin", "proxyctl") if err := os.MkdirAll(filepath.Dir(binary), 0o700); err != nil { t.Fatal(err) } build := exec.Command("go", "build", "-tags", "proxyctl_process_test", "-o", binary, ".") if output, err := build.CombinedOutput(); err != nil { t.Fatalf("build process fixture: %v\n%s", err, output) } t.Run("normal exit", func(t *testing.T) { process := startPTYProcess(t, binary, isolatedPTYEnvironment(t, filepath.Join(root, "normal"))) process.waitForOutput(t, "Target availability and state checks completed") process.write(t, "q") process.waitForExit(t) assertAlternateScreenRestored(t, process.output()) }) t.Run("exit deferred after Target mutation starts", func(t *testing.T) { processRoot := filepath.Join(root, "deferred") environment := isolatedPTYEnvironment(t, processRoot) fifo := filepath.Join(processRoot, "mutation-release.fifo") if err := syscall.Mkfifo(fifo, 0o600); err != nil { t.Fatal(err) } started := filepath.Join(processRoot, "mutation-started") environment = append(environment, "PROXYCTL_PROCESS_TEST_MUTATION_FIFO="+fifo, "PROXYCTL_PROCESS_TEST_MUTATION_STARTED="+started, ) writeConfig(t, filepath.Join(processRoot, "config", "proxyctl", "profiles.json"), `{ "profiles": [{"name":"office","mode":"single","singleUrl":"http://proxy.example:8080"}], "activeProfile": "office" }`) process := startPTYProcess(t, binary, environment) process.waitForOutput(t, "Disabled Blocking mutation Target") process.write(t, "\x1b[B ") waitForPath(t, started) process.write(t, "\x03") process.waitForOutput(t, "Exit requested — waiting for the current mutation to finish safely") process.assertRunning(t) release, err := os.OpenFile(fifo, os.O_WRONLY, 0) if err != nil { t.Fatalf("open mutation release FIFO: %v", err) } if _, err := release.Write([]byte("release\n")); err != nil { t.Fatalf("release mutation: %v", err) } if err := release.Close(); err != nil { t.Fatal(err) } process.waitForExit(t) assertAlternateScreenRestored(t, process.output()) }) } type ptyProcess struct { command *exec.Cmd terminal *os.File done chan error readDone chan struct{} changed chan struct{} mu sync.Mutex buffer bytes.Buffer } func startPTYProcess(t *testing.T, binary string, environment []string) *ptyProcess { t.Helper() command := exec.Command(binary) command.Env = environment terminal, err := pty.StartWithSize(command, &pty.Winsize{Rows: 36, Cols: 120}) if err != nil { t.Fatalf("start process under PTY: %v", err) } process := &ptyProcess{ command: command, terminal: terminal, done: make(chan error, 1), readDone: make(chan struct{}), changed: make(chan struct{}, 1), } go func() { _, _ = io.Copy(process, terminal) close(process.readDone) }() go func() { process.done <- command.Wait() }() t.Cleanup(func() { _ = terminal.Close() if command.Process != nil { _ = command.Process.Kill() } }) return process } func (p *ptyProcess) Write(data []byte) (int, error) { p.mu.Lock() _, _ = p.buffer.Write(data) p.mu.Unlock() select { case p.changed <- struct{}{}: default: } return len(data), nil } func (p *ptyProcess) write(t *testing.T, value string) { t.Helper() if _, err := p.terminal.WriteString(value); err != nil { t.Fatalf("write PTY input: %v", err) } } func (p *ptyProcess) output() string { p.mu.Lock() defer p.mu.Unlock() return p.buffer.String() } func (p *ptyProcess) waitForOutput(t *testing.T, expected string) { t.Helper() timer := time.NewTimer(10 * time.Second) defer timer.Stop() for { if strings.Contains(p.output(), expected) { return } select { case <-p.changed: case err := <-p.done: t.Fatalf("process exited before output %q: %v\n%s", expected, err, p.output()) case <-timer.C: t.Fatalf("timed out waiting for output %q\n%s", expected, p.output()) } } } func (p *ptyProcess) assertRunning(t *testing.T) { t.Helper() select { case err := <-p.done: t.Fatalf("process exited while Target mutation was blocked: %v\n%s", err, p.output()) default: } if err := p.command.Process.Signal(syscall.Signal(0)); err != nil { t.Fatalf("blocked process is not alive: %v", err) } } func (p *ptyProcess) waitForExit(t *testing.T) { t.Helper() select { case err := <-p.done: if err != nil { t.Fatalf("PTY process exited with error: %v\n%s", err, p.output()) } case <-time.After(10 * time.Second): t.Fatalf("timed out waiting for PTY process exit\n%s", p.output()) } _ = p.terminal.Close() select { case <-p.readDone: case <-time.After(2 * time.Second): t.Fatal("PTY output reader did not finish") } } func isolatedPTYEnvironment(t *testing.T, root string) []string { t.Helper() for _, path := range []string{filepath.Join(root, "home"), filepath.Join(root, "config"), filepath.Join(root, "state")} { if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } } return append(isolatedProcessEnvironment(root), "TERM=xterm-256color", "NO_COLOR=1") } func waitForPath(t *testing.T, path string) { t.Helper() timer := time.NewTimer(10 * time.Second) defer timer.Stop() ticker := time.NewTicker(5 * time.Millisecond) defer ticker.Stop() for { if _, err := os.Stat(path); err == nil { return } else if !errors.Is(err, os.ErrNotExist) { t.Fatalf("inspect synchronization path: %v", err) } select { case <-ticker.C: case <-timer.C: t.Fatalf("timed out waiting for synchronization path %s", path) } } } func assertAlternateScreenRestored(t *testing.T, output string) { t.Helper() enter := strings.Index(output, "\x1b[?1049h") leave := strings.LastIndex(output, "\x1b[?1049l") if enter < 0 || leave <= enter { t.Fatalf("alternate screen was not entered and restored in order: %q", output) } }