mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Resolved an invalid parameter error (-2147024809) during PTY creation on Windows 11 22H2 (but not only) when connecting via JetBrains Toolbox which spawns the native SSH client with `-tt` forcing PTY allocation even though there is no "terminal" on the client side to query its size. CreatePseudoConsole doesn't accept a 0x0 (zero width and zero height) console size and unfortunately, there is NO explicit documentation in the official Microsoft documentation that states the minimum valid values or explicitly prohibits 0x0. Looking at real-world implementations in the search results, all examples use reasonable non-zero values. I tested this with a local Windows VM registered to dev.coder.com i.e. externally managed workspace. Fixes: #20468
277 lines
7.3 KiB
Go
277 lines
7.3 KiB
Go
//go:build windows
|
|
|
|
package pty
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
var (
|
|
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
|
procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole")
|
|
procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole")
|
|
procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole")
|
|
)
|
|
|
|
// See: https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session
|
|
func newPty(opt ...Option) (*ptyWindows, error) {
|
|
var opts ptyOptions
|
|
for _, o := range opt {
|
|
o(&opts)
|
|
}
|
|
|
|
// We use the CreatePseudoConsole API which was introduced in build 17763
|
|
vsn := windows.RtlGetVersion()
|
|
if vsn.MajorVersion < 10 ||
|
|
vsn.BuildNumber < 17763 {
|
|
// If the CreatePseudoConsole API is not available, we fall back to a simpler
|
|
// implementation that doesn't create an actual PTY - just uses os.Pipe
|
|
return nil, xerrors.Errorf("pty not supported")
|
|
}
|
|
|
|
pty := &ptyWindows{
|
|
opts: opts,
|
|
}
|
|
|
|
var err error
|
|
pty.inputRead, pty.inputWrite, err = os.Pipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pty.outputRead, pty.outputWrite, err = os.Pipe()
|
|
if err != nil {
|
|
_ = pty.inputRead.Close()
|
|
_ = pty.inputWrite.Close()
|
|
return nil, err
|
|
}
|
|
|
|
// Default dimensions
|
|
width, height := 80, 80
|
|
if opts.sshReq != nil {
|
|
if w := opts.sshReq.Window.Width; w > 0 && w <= 65535 {
|
|
width = w
|
|
}
|
|
if h := opts.sshReq.Window.Height; h > 0 && h <= 65535 {
|
|
height = h
|
|
}
|
|
}
|
|
|
|
consoleSize := uintptr(width) + (uintptr(height) << 16)
|
|
|
|
ret, _, err := procCreatePseudoConsole.Call(
|
|
consoleSize,
|
|
uintptr(pty.inputRead.Fd()),
|
|
uintptr(pty.outputWrite.Fd()),
|
|
0,
|
|
uintptr(unsafe.Pointer(&pty.console)),
|
|
)
|
|
// CreatePseudoConsole returns S_OK on success, as per:
|
|
// https://learn.microsoft.com/en-us/windows/console/createpseudoconsole
|
|
if windows.Handle(ret) != windows.S_OK {
|
|
_ = pty.Close()
|
|
return nil, xerrors.Errorf("create pseudo console (%d): %w", int32(ret), err)
|
|
}
|
|
|
|
return pty, nil
|
|
}
|
|
|
|
type ptyWindows struct {
|
|
opts ptyOptions
|
|
console windows.Handle
|
|
|
|
outputWrite *os.File
|
|
outputRead *os.File
|
|
inputWrite *os.File
|
|
inputRead *os.File
|
|
|
|
closeMutex sync.Mutex
|
|
closed bool
|
|
}
|
|
|
|
type windowsProcess struct {
|
|
// cmdDone protects access to cmdErr: anything reading cmdErr should read from cmdDone first.
|
|
cmdDone chan any
|
|
cmdErr error
|
|
proc *os.Process
|
|
pw *ptyWindows
|
|
}
|
|
|
|
// Name returns the TTY name on Windows.
|
|
//
|
|
// Not implemented.
|
|
func (p *ptyWindows) Name() string {
|
|
return ""
|
|
}
|
|
|
|
func (p *ptyWindows) Output() ReadWriter {
|
|
return ReadWriter{
|
|
Reader: p.outputRead,
|
|
Writer: p.outputWrite,
|
|
}
|
|
}
|
|
|
|
func (p *ptyWindows) OutputReader() io.Reader {
|
|
return p.outputRead
|
|
}
|
|
|
|
func (p *ptyWindows) Input() ReadWriter {
|
|
return ReadWriter{
|
|
Reader: p.inputRead,
|
|
Writer: p.inputWrite,
|
|
}
|
|
}
|
|
|
|
func (p *ptyWindows) InputWriter() io.Writer {
|
|
return p.inputWrite
|
|
}
|
|
|
|
func (p *ptyWindows) Resize(height uint16, width uint16) error {
|
|
// hold the lock, so we don't race with anyone trying to close the console
|
|
p.closeMutex.Lock()
|
|
defer p.closeMutex.Unlock()
|
|
if p.closed || p.console == windows.InvalidHandle {
|
|
return ErrClosed
|
|
}
|
|
// Taken from: https://github.com/microsoft/hcsshim/blob/54a5ad86808d761e3e396aff3e2022840f39f9a8/internal/winapi/zsyscall_windows.go#L144
|
|
ret, _, err := procResizePseudoConsole.Call(uintptr(p.console), uintptr(*((*uint32)(unsafe.Pointer(&windows.Coord{
|
|
Y: int16(height),
|
|
X: int16(width),
|
|
})))))
|
|
if windows.Handle(ret) != windows.S_OK {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// closeConsoleNoLock closes the console handle, and sets it to
|
|
// windows.InvalidHandle. It must be called with p.closeMutex held.
|
|
func (p *ptyWindows) closeConsoleNoLock() error {
|
|
// if we are running a command in the PTY, the corresponding *windowsProcess
|
|
// may have already closed the PseudoConsole when the command exited, so that
|
|
// output reads can get to EOF. In that case, we don't need to close it
|
|
// again here.
|
|
if p.console != windows.InvalidHandle {
|
|
// ClosePseudoConsole has no return value and typically the syscall
|
|
// returns S_FALSE (a success value). We could ignore the return value
|
|
// and error here but we handle anyway, it just in case.
|
|
//
|
|
// Note that ClosePseudoConsole is a blocking system call and may write
|
|
// a final frame to the output buffer (p.outputWrite), so there must be
|
|
// a consumer (p.outputRead) to ensure we don't block here indefinitely.
|
|
//
|
|
// https://docs.microsoft.com/en-us/windows/console/closepseudoconsole
|
|
ret, _, err := procClosePseudoConsole.Call(uintptr(p.console))
|
|
if winerrorFailed(ret) {
|
|
return xerrors.Errorf("close pseudo console (%d): %w", ret, err)
|
|
}
|
|
p.console = windows.InvalidHandle
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *ptyWindows) Close() error {
|
|
p.closeMutex.Lock()
|
|
defer p.closeMutex.Unlock()
|
|
if p.closed {
|
|
return nil
|
|
}
|
|
|
|
// Close the pseudo console, this will also terminate the process attached
|
|
// to this pty. If it was created via Start(), this also unblocks close of
|
|
// the readers below.
|
|
err := p.closeConsoleNoLock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only set closed after the console has been successfully closed.
|
|
p.closed = true
|
|
|
|
// Close the pipes ensuring that the writer is closed before the respective
|
|
// reader, otherwise closing the reader may block indefinitely. Note that
|
|
// outputWrite and inputRead are unset when we Start() a new process.
|
|
if p.outputWrite != nil {
|
|
_ = p.outputWrite.Close()
|
|
}
|
|
_ = p.outputRead.Close()
|
|
_ = p.inputWrite.Close()
|
|
if p.inputRead != nil {
|
|
_ = p.inputRead.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *windowsProcess) waitInternal() {
|
|
// put this on the bottom of the defer stack since the next defer can write to p.cmdErr
|
|
defer close(p.cmdDone)
|
|
defer func() {
|
|
// close the pseudoconsole handle when the process exits, if it hasn't already been closed.
|
|
// this is important because the PseudoConsole (conhost.exe) holds the write-end
|
|
// of the output pipe. If it is not closed, reads on that pipe will block, even though
|
|
// the command has exited.
|
|
// c.f. https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/
|
|
p.pw.closeMutex.Lock()
|
|
defer p.pw.closeMutex.Unlock()
|
|
|
|
err := p.pw.closeConsoleNoLock()
|
|
// if we already have an error from the command, prefer that error
|
|
// but if the command succeeded and closing the PseudoConsole fails
|
|
// then record that error so that we have a chance to see it
|
|
if err != nil && p.cmdErr == nil {
|
|
p.cmdErr = err
|
|
}
|
|
}()
|
|
|
|
state, err := p.proc.Wait()
|
|
if err != nil {
|
|
p.cmdErr = err
|
|
return
|
|
}
|
|
if !state.Success() {
|
|
p.cmdErr = &exec.ExitError{ProcessState: state}
|
|
return
|
|
}
|
|
}
|
|
|
|
func (p *windowsProcess) Wait() error {
|
|
<-p.cmdDone
|
|
return p.cmdErr
|
|
}
|
|
|
|
func (p *windowsProcess) Kill() error {
|
|
return p.proc.Kill()
|
|
}
|
|
|
|
func (p *windowsProcess) Signal(sig os.Signal) error {
|
|
// Windows doesn't support signals.
|
|
return p.Kill()
|
|
}
|
|
|
|
// killOnContext waits for the context to be done and kills the process, unless it exits on its own first.
|
|
func (p *windowsProcess) killOnContext(ctx context.Context) {
|
|
select {
|
|
case <-p.cmdDone:
|
|
return
|
|
case <-ctx.Done():
|
|
p.Kill()
|
|
}
|
|
}
|
|
|
|
// winerrorFailed returns true if the syscall failed, this function
|
|
// assumes the return value is a 32-bit integer, like HRESULT.
|
|
//
|
|
// https://learn.microsoft.com/en-us/windows/win32/api/winerror/nf-winerror-failed
|
|
func winerrorFailed(r1 uintptr) bool {
|
|
return int32(r1) < 0
|
|
}
|