Files
kandev/apps/backend/internal/agentctl/server/process/pty_windows_test.go
T

83 lines
3.1 KiB
Go

//go:build windows
package process
import (
"os/exec"
"strings"
"testing"
)
// TestWindowsPTY_DoubleCloseSafe guards against the double-free that crashed
// the backend on Windows when a terminal tab was closed (issue #894). Both
// InteractiveRunner.Stop and InteractiveRunner.wait close the PTY handle, so
// the wrapper must collapse the second call into a no-op — otherwise the
// underlying conpty library double-frees its Windows handles and triggers
// STATUS_HEAP_CORRUPTION.
func TestWindowsPTY_DoubleCloseSafe(t *testing.T) {
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
pty, err := startPTYWithSize(cmd, 80, 24)
if err != nil {
t.Fatalf("startPTYWithSize: %v", err)
}
if err := pty.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
// Second Close must not panic, must not double-free, and must return the
// same error value as the first call.
if err := pty.Close(); err != nil {
t.Fatalf("second Close returned error: %v (expected nil from sync.Once)", err)
}
}
// TestResolveConPtyCmdLine_CmdShimIsWrapped guards against the regression that
// broke passthrough/login sessions for every npm-installed CLI on Windows:
// CreateProcessW (used by ConPTY) doesn't apply PATHEXT and can't execute
// .cmd / .bat scripts directly. exec.Command resolves the binary into
// cmd.Path; we must (a) use that resolved path instead of the bare Args[0]
// and (b) wrap batch scripts with cmd.exe /c so the interpreter runs them.
func TestResolveConPtyCmdLine_CmdShimIsWrapped(t *testing.T) {
cmd := &exec.Cmd{
Path: `C:\Users\test\AppData\Roaming\npm\opencode.cmd`,
Args: []string{"opencode", "--model", "big-pickle"},
}
got := resolveConPtyCmdLine(cmd)
want := `cmd.exe /c C:\Users\test\AppData\Roaming\npm\opencode.cmd --model big-pickle`
if got != want {
t.Errorf("resolveConPtyCmdLine(.cmd shim):\n got: %s\n want: %s", got, want)
}
}
// TestResolveConPtyCmdLine_ExePassesThrough confirms .exe binaries (and any
// non-batch path) skip the cmd.exe wrapper — wrapping would lose access to
// signals and add a needless intermediate process for the common case.
func TestResolveConPtyCmdLine_ExePassesThrough(t *testing.T) {
cmd := &exec.Cmd{
Path: `C:\Windows\System32\cmd.exe`,
Args: []string{"cmd.exe", "/c", "exit", "0"},
}
got := resolveConPtyCmdLine(cmd)
if strings.HasPrefix(got, "cmd.exe /c cmd.exe") {
t.Errorf("resolveConPtyCmdLine wrapped a non-batch executable: %s", got)
}
}
// TestResolveConPtyCmdLine_PathSubstitutedForArgs0 confirms cmd.Path is what
// CreateProcessW receives, not the unresolved cmd.Args[0]. Without this,
// "opencode" reaches CreateProcessW and fails because the loader can't
// resolve a bare name without PATHEXT.
func TestResolveConPtyCmdLine_PathSubstitutedForArgs0(t *testing.T) {
cmd := &exec.Cmd{
Path: `C:\Tools\foo.exe`,
Args: []string{"foo", "bar"},
}
got := resolveConPtyCmdLine(cmd)
if !strings.HasPrefix(got, `C:\Tools\foo.exe `) {
t.Errorf("expected cmd.Path as first token; got: %s", got)
}
if strings.HasPrefix(got, "foo ") {
t.Errorf("first token is unresolved Args[0]; got: %s", got)
}
}