All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported regression introduced in v0.7.6: PowerShell / cmd open
in a separate external console window instead of attaching to the
xterm.js tab (v0.7.5 worked).
Root cause: the ConPTY wiring used
attrList.Update(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
unsafe.Pointer(&hPC), // ← wrong
unsafe.Sizeof(hPC))
The PSEUDOCONSOLE attribute is a Win32 API quirk: lpValue must be
the HPCON *value* (cast to PVOID), not a pointer to the local
variable holding the handle. With &hPC the kernel reads garbage,
silently drops the attribute, and CreateProcessW spawns the child
with a fresh console — hence the external window.
Fix is one line:
unsafe.Pointer(uintptr(hPC))
Confirmed against Microsoft's EchoCon sample and Go libraries that
work in production (UserExistsError/conpty, aymanbagabas/go-pty).
- internal/version/version.go: 0.7.7 → 0.7.8
- CHANGELOG.md: v0.7.8 entry with the diagnostic write-up
272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
//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)
|