Skip to content

term: Windows gets a real PTY — ConPTY under a Job Object (GDK-891) - #110

Closed
midagedev wants to merge 1 commit into
mainfrom
gdk891-conpty
Closed

midagedev wants to merge 1 commit into
mainfrom
gdk891-conpty

Conversation

@midagedev

Copy link
Copy Markdown
Owner

session_windows.go was an honest refusal (GDK-862). It is an implementation now — CreatePseudoConsole plus a child spawned with PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, cgo-free on x/sys/windows, no new module.

Spawn is a direct CreateProcess rather than os/exec: attaching a child to a pseudoconsole needs an attribute list on STARTUPINFOEX, and syscall.SysProcAttr has no field that can carry one. wait() is WaitForSingleObject on the raw handle.

The tree has an owner here, so the ladder changes shape. The unix half signals a process group and then walks the controlling terminal to find the jobs a shell moved out of it, because nothing owns the tree. Windows has the Job Object: hangup closes the pseudoconsole, kill terminates the job, and KILL_ON_JOB_CLOSE backs both up. The child starts CREATE_SUSPENDED and joins the job before its first instruction, so "every descendant is in the job" is a theorem rather than a race AssignProcessToJobObject has to win. sessionMembers becomes a job query instead of the nil it returned.

Two unix contracts have no twin, and say so where they are read. winsize answers from the last size resize accepted — ConPTY has no GetPseudoConsoleSize, and the unix half trusts only the kernel's read-back. PID is the shell's pid and nothing else: there is no process group to also be.

Handles leak by default, so the file opens with a ledger naming the single closer of each of the seven and the order they go in, and the control-plane calls sit behind a lock that zeroes each field as it closes — a syscall on a recycled handle value is not the benign EBADF a stale fd is.

Why this is a PR: nothing in the file executes on macOS. The new step in the Windows job is the runtime proof, and it runs the four axes that matter — a command's output makes the round trip, a resize reaches the child, kill takes the grandchild with it, and closing twice is safe. The third is the Job Object contract itself.

What could be measured locally was: both GOOS builds, go vet on each, the full native suite, gofmt, sourcelint.sh, the staticcheck.sh GOOS matrix. The round that wrote this reported an explicit list of what it could not execute, and the CI step exists to answer it.

🤖 Generated with Claude Code

session_windows.go was an honest refusal (GDK-862): Create returned
ErrUnsupportedPlatform and the pane said "not on Windows yet" and meant
it. It is an implementation now — CreatePseudoConsole plus a child spawned
with PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, cgo-free on x/sys/windows, no
new module.

Spawn is a direct CreateProcess rather than os/exec: attaching a child to
a pseudoconsole needs an attribute list on STARTUPINFOEX, and
syscall.SysProcAttr has no field that can carry one. wait() is
WaitForSingleObject on the raw handle — the same observable answer,
rebuilt.

The unix half has no owner of the process tree, which is why it signals a
process group and then walks the controlling terminal to find the jobs a
shell moved out of it. Windows has an owner, so the ladder maps onto the
Job Object instead: hangup closes the pseudoconsole (conhost tears the
session down), kill terminates the job, and KILL_ON_JOB_CLOSE backs both
up when the handle goes. The child starts CREATE_SUSPENDED and joins the
job before its first instruction, so "every descendant is in the job" is a
theorem rather than a race AssignProcessToJobObject has to win. That also
makes sessionMembers a job query instead of the nil it used to return —
enumeration becomes a lookup, with no revoked-device or recycled-pid
hazard to guard against.

Two unix contracts have no Windows twin and say so where they are read.
winsize answers from the last size resize accepted, because ConPTY has no
GetPseudoConsoleSize — the unix half trusts only the kernel's read-back,
and this one cannot. PID is the shell's pid and nothing else: there is no
process group to also be.

Handles leak by default here, so the file opens with a ledger naming the
single closer of each of the seven and the order they go in, and the
control-plane calls sit behind a lock that zeroes each field as it closes
— a syscall on a recycled handle value is not the benign EBADF a stale fd
is on unix.

Runtime proof is the Windows CI job; nothing in this file executes on a
developer's macOS. The new step runs the four axes that matter — a
command's output makes the round trip, a resize reaches the child, kill
takes the grandchild with it, and closing twice is safe — and the last of
those is the Job Object contract itself. What could be measured here was
measured: both GOOS builds, vet, the full native suite, sourcelint, the
staticcheck matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@midagedev

Copy link
Copy Markdown
Owner Author

Holding this open a little longer, not for review but because its Windows job is the only place the code runs: three tests read zero bytes off the pseudoconsole and the job is empty, so the read path is dead. A round is adding the diagnostics that tell apart 'the child never started', 'it started outside the job' and 'the output pipe is the wrong way round' — the current log cannot. Once that lands the work goes to main directly.

AI-authored comment.

@midagedev

Copy link
Copy Markdown
Owner Author

Landed on main as 0541173 + 311ae34. The second commit is what the Windows job here found: lpValue was the address of the box holding the pseudoconsole handle rather than the handle, and separately JOBOBJECT_BASIC_PROCESS_ID_LIST's ULONG_PTR counts were mirrored as uint32, so the pid list read the high half of a 64-bit field. The second one now has a byte-decoder test that runs everywhere.

Closing the PR — work in this repo goes to main directly from here. The runtime verdict will land on main's own run.

AI-authored comment.

@midagedev midagedev closed this Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant