//go:build windows package api // Windows ConPTY (Pseudo Console) backend for the terminal tab. // // creack/pty/v2 returns "operating system not supported" on Windows, so the // previous fallback was plain stdin/stdout pipes (terminal_session.go:: // pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl // detect "no TTY" and either go silent or wait forever — the user sees a // black screen. This file implements a real pseudo console using the // kernel32 ConPTY API, so the spawned shell behaves as if it were attached // to a real terminal: prompts render, ANSI escapes are honoured, resize // events propagate. // // Requires Windows 10 v1809 (build 17763) or newer. On older hosts // CreatePseudoConsole returns an error and startTermSession_windows falls // back to pipeSession. import ( "fmt" "io" "os/exec" "sync" "syscall" "unsafe" "golang.org/x/sys/windows" ) const ( procThreadAttributePseudoconsole = 0x00020016 extendedStartupinfoPresent = 0x00080000 createUnicodeEnvironment = 0x00000400 ) // conptySession drives a Windows pseudo console. type conptySession struct { hPC windows.Handle inWrite windows.Handle outRead windows.Handle procInfo windows.ProcessInformation closed bool mu sync.Mutex } // startConptySession spins up the pseudo console, plumbs the pipes, and // CreateProcessW's the child with the PC attached via STARTUPINFOEX. func startConptySession(cmd *exec.Cmd) (termSession, error) { // 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read). var inRead, inWrite, outRead, outWrite windows.Handle if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil { return nil, fmt.Errorf("create stdin pipe: %w", err) } if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil { windows.CloseHandle(inRead) windows.CloseHandle(inWrite) return nil, fmt.Errorf("create stdout pipe: %w", err) } // 2. Create the pseudo console. After this call ConPTY effectively owns // the child-facing pipe ends (inRead, outWrite); we close our copy. var hPC windows.Handle sz := windows.Coord{X: 120, Y: 30} if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil { windows.CloseHandle(inRead) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) windows.CloseHandle(outWrite) return nil, fmt.Errorf("CreatePseudoConsole: %w", err) } windows.CloseHandle(inRead) windows.CloseHandle(outWrite) // 3. Allocate an attribute list with one slot for the PC attribute. attrList, err := windows.NewProcThreadAttributeList(1) if err != nil { windows.ClosePseudoConsole(hPC) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err) } // PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is a quirk of the Win32 API: lpValue // is the HPCON *value* (cast to PVOID), not a pointer to the handle. If // we pass &hPC the kernel reads garbage, the PC attribute is silently // ignored, and cmd/pwsh get their own external console window — which is // exactly the regression v0.7.6 introduced. The cbSize stays the size of // the handle (8 bytes on amd64). Reference: Microsoft EchoCon sample. if err := attrList.Update( procThreadAttributePseudoconsole, unsafe.Pointer(uintptr(hPC)), unsafe.Sizeof(hPC), ); err != nil { attrList.Delete() windows.ClosePseudoConsole(hPC) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) return nil, fmt.Errorf("attrList.Update: %w", err) } // 4. Build command line. cmdLine, err := buildCommandLine(cmd) if err != nil { attrList.Delete() windows.ClosePseudoConsole(hPC) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) return nil, err } cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine) if err != nil { attrList.Delete() windows.ClosePseudoConsole(hPC) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) return nil, err } // 5. Build the env block (key=value\0...\0\0). var envBlock *uint16 if cmd.Env != nil { eb, err := makeEnvBlock(cmd.Env) if err != nil { attrList.Delete() windows.ClosePseudoConsole(hPC) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) return nil, err } envBlock = eb } si := windows.StartupInfoEx{} si.StartupInfo.Cb = uint32(unsafe.Sizeof(si)) si.ProcThreadAttributeList = attrList.List() flags := uint32(extendedStartupinfoPresent) if envBlock != nil { flags |= createUnicodeEnvironment } var pi windows.ProcessInformation err = windows.CreateProcess( nil, // application name (null = parse from cmdline) cmdLineUTF16, nil, // process security attrs nil, // thread security attrs false, // inherit handles (ConPTY hands handles via attribute list) flags, envBlock, nil, // working dir &si.StartupInfo, &pi, ) attrList.Delete() if err != nil { windows.ClosePseudoConsole(hPC) windows.CloseHandle(inWrite) windows.CloseHandle(outRead) return nil, fmt.Errorf("CreateProcess: %w", err) } return &conptySession{ hPC: hPC, inWrite: inWrite, outRead: outRead, procInfo: pi, }, nil } func (s *conptySession) Read(p []byte) (int, error) { var n uint32 err := windows.ReadFile(s.outRead, p, &n, nil) if err != nil { if n > 0 { return int(n), nil } return 0, io.EOF } return int(n), nil } func (s *conptySession) Write(p []byte) (int, error) { var n uint32 err := windows.WriteFile(s.inWrite, p, &n, nil) if err != nil { return int(n), err } return int(n), nil } func (s *conptySession) Resize(rows, cols uint16) error { return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)}) } func (s *conptySession) Close() error { s.mu.Lock() if s.closed { s.mu.Unlock() return nil } s.closed = true s.mu.Unlock() // Order matters: close the pseudo console first so the child sees EOF, // then close our pipe ends, then terminate / close handles. windows.ClosePseudoConsole(s.hPC) windows.CloseHandle(s.inWrite) windows.CloseHandle(s.outRead) if s.procInfo.Process != 0 { windows.TerminateProcess(s.procInfo.Process, 0) windows.CloseHandle(s.procInfo.Process) } if s.procInfo.Thread != 0 { windows.CloseHandle(s.procInfo.Thread) } return nil } func (s *conptySession) Wait() error { if s.procInfo.Process == 0 { return nil } _, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE) return err } func (s *conptySession) Pid() int { return int(s.procInfo.ProcessId) } // --- helpers ----------------------------------------------------------- // buildCommandLine produces the Windows command-line string for an // *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and // quotes per Windows convention). func buildCommandLine(cmd *exec.Cmd) (string, error) { if cmd.Path == "" { return "", fmt.Errorf("empty cmd.Path") } parts := []string{cmd.Path} if len(cmd.Args) > 1 { parts = append(parts, cmd.Args[1:]...) } out := syscall.EscapeArg(parts[0]) for _, a := range parts[1:] { out += " " + syscall.EscapeArg(a) } return out, nil } // makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block // format: key=value\0key=value\0\0. func makeEnvBlock(env []string) (*uint16, error) { var buf []uint16 for _, kv := range env { s, err := syscall.UTF16FromString(kv) if err != nil { return nil, err } buf = append(buf, s...) // includes trailing NUL } buf = append(buf, 0) // final terminator if len(buf) == 0 { return nil, nil } return &buf[0], nil } // Compile-time interface assertion. var _ termSession = (*conptySession)(nil)