detached ffe9b7e

fix(pty): resize via raw master fd — keep poller registration

commit
ffe9b7ef9dcd00a1d042c0bf2b60e75e7567da50
author
brice · · 2026-07-02 11:06 UTC
parent
7ac6830
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +22 -27
M internal/adapters/pty/pty_linux.go +20 -11
@@ -108,9 +108,6 @@ type linuxPTY struct {
108108 master *os.File
109109 cmd *exec.Cmd
110110
111- waitOnce sync.Once
112- waitErr error
113-
114111 closeOnce sync.Once
115112 closeErr error
116113 }
@@ -137,8 +134,24 @@ func (p *linuxPTY) Write(b []byte) (int, error) {
137134
138135 // Resize updates the terminal window size, which also delivers SIGWINCH to the
139136 // child's foreground process group.
137+//
138+// The ioctl goes through SyscallConn rather than os.File.Fd(): Fd() would flip
139+// the master to blocking mode and detach it from the runtime poller, turning
140+// every subsequent Read into a thread-parking syscall and breaking the
141+// Close-unblocks-Read behavior. Control also pins the fd for the duration and
142+// fails cleanly (os.ErrClosed) after Close.
140143 func (p *linuxPTY) Resize(sz domain.Size) error {
141- return setWinsize(int(p.master.Fd()), sz)
144+ rc, err := p.master.SyscallConn()
145+ if err != nil {
146+ return fmt.Errorf("pty: resize: %w", err)
147+ }
148+ var ioctlErr error
149+ if err := rc.Control(func(fd uintptr) {
150+ ioctlErr = setWinsize(int(fd), sz)
151+ }); err != nil {
152+ return fmt.Errorf("pty: resize: %w", err)
153+ }
154+ return ioctlErr
142155 }
143156
144157 // Pid reports the child process id.
@@ -159,9 +172,11 @@ func (p *linuxPTY) Close() error {
159172 // leader thanks to Setsid). Ignore errors: the child may already be gone.
160173 _ = unix.Kill(-pid, unix.SIGHUP)
161174
175+ // Reap the child (exit status is intentionally discarded; the port has no
176+ // Wait — "child gone" surfaces to callers as io.EOF on Read).
162177 reaped := make(chan struct{})
163178 go func() {
164- p.wait()
179+ _ = p.cmd.Wait()
165180 close(reaped)
166181 }()
167182
@@ -177,12 +192,6 @@ func (p *linuxPTY) Close() error {
177192 return p.closeErr
178193 }
179194
180-// wait reaps the child exactly once, memoizing the exit error.
181-func (p *linuxPTY) wait() error {
182- p.waitOnce.Do(func() { p.waitErr = p.cmd.Wait() })
183- return p.waitErr
184-}
185-
186195 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
187196 func setWinsize(fd int, sz domain.Size) error {
188197 return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{
@@ -108,9 +108,6 @@ type linuxPTY struct {
108 master *os.File108 master *os.File
109 cmd *exec.Cmd109 cmd *exec.Cmd
110 110
111- waitOnce sync.Once
112- waitErr error
113-
114 closeOnce sync.Once111 closeOnce sync.Once
115 closeErr error112 closeErr error
116 }113 }
@@ -137,8 +134,24 @@ func (p *linuxPTY) Write(b []byte) (int, error) {
137 134
138 // Resize updates the terminal window size, which also delivers SIGWINCH to the135 // Resize updates the terminal window size, which also delivers SIGWINCH to the
139 // child's foreground process group.136 // child's foreground process group.
137+//
138+// The ioctl goes through SyscallConn rather than os.File.Fd(): Fd() would flip
139+// the master to blocking mode and detach it from the runtime poller, turning
140+// every subsequent Read into a thread-parking syscall and breaking the
141+// Close-unblocks-Read behavior. Control also pins the fd for the duration and
142+// fails cleanly (os.ErrClosed) after Close.
140 func (p *linuxPTY) Resize(sz domain.Size) error {143 func (p *linuxPTY) Resize(sz domain.Size) error {
141- return setWinsize(int(p.master.Fd()), sz)144+ rc, err := p.master.SyscallConn()
145+ if err != nil {
146+ return fmt.Errorf("pty: resize: %w", err)
147+ }
148+ var ioctlErr error
149+ if err := rc.Control(func(fd uintptr) {
150+ ioctlErr = setWinsize(int(fd), sz)
151+ }); err != nil {
152+ return fmt.Errorf("pty: resize: %w", err)
153+ }
154+ return ioctlErr
142 }155 }
143 156
144 // Pid reports the child process id.157 // Pid reports the child process id.
@@ -159,9 +172,11 @@ func (p *linuxPTY) Close() error {
159 // leader thanks to Setsid). Ignore errors: the child may already be gone.172 // leader thanks to Setsid). Ignore errors: the child may already be gone.
160 _ = unix.Kill(-pid, unix.SIGHUP)173 _ = unix.Kill(-pid, unix.SIGHUP)
161 174
175+ // Reap the child (exit status is intentionally discarded; the port has no
176+ // Wait — "child gone" surfaces to callers as io.EOF on Read).
162 reaped := make(chan struct{})177 reaped := make(chan struct{})
163 go func() {178 go func() {
164- p.wait()179+ _ = p.cmd.Wait()
165 close(reaped)180 close(reaped)
166 }()181 }()
167 182
@@ -177,12 +192,6 @@ func (p *linuxPTY) Close() error {
177 return p.closeErr192 return p.closeErr
178 }193 }
179 194
180-// wait reaps the child exactly once, memoizing the exit error.
181-func (p *linuxPTY) wait() error {
182- p.waitOnce.Do(func() { p.waitErr = p.cmd.Wait() })
183- return p.waitErr
184-}
185-
186 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.195 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
187 func setWinsize(fd int, sz domain.Size) error {196 func setWinsize(fd int, sz domain.Size) error {
188 return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{197 return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{
M internal/adapters/pty/pty_linux_test.go +2 -16
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "io"
99 "os"
10+ "strconv"
1011 "strings"
1112 "syscall"
1213 "testing"
@@ -159,7 +160,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
159160 err = syscall.Kill(pid, 0)
160161 if err == nil {
161162 // Still visible: ensure it is a zombie's parent-less remnant is not our child.
162- data, rerr := os.ReadFile("/proc/" + itoa(pid) + "/status")
163+ data, rerr := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
163164 if rerr == nil {
164165 require.Contains(t, string(data), "State:\tZ", "child still alive and not a zombie")
165166 }
@@ -231,18 +232,3 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
231232 }
232233 require.Contains(t, acc, "GOTWINCH")
233234 }
234-
235-// itoa avoids importing strconv just for a couple call sites in this file.
236-func itoa(i int) string {
237- if i == 0 {
238- return "0"
239- }
240- var b [20]byte
241- pos := len(b)
242- for i > 0 {
243- pos--
244- b[pos] = byte('0' + i%10)
245- i /= 10
246- }
247- return string(b[pos:])
248-}
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "io"8 "io"
9 "os"9 "os"
10+ "strconv"
10 "strings"11 "strings"
11 "syscall"12 "syscall"
12 "testing"13 "testing"
@@ -159,7 +160,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
159 err = syscall.Kill(pid, 0)160 err = syscall.Kill(pid, 0)
160 if err == nil {161 if err == nil {
161 // Still visible: ensure it is a zombie's parent-less remnant is not our child.162 // Still visible: ensure it is a zombie's parent-less remnant is not our child.
162- data, rerr := os.ReadFile("/proc/" + itoa(pid) + "/status")163+ data, rerr := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
163 if rerr == nil {164 if rerr == nil {
164 require.Contains(t, string(data), "State:\tZ", "child still alive and not a zombie")165 require.Contains(t, string(data), "State:\tZ", "child still alive and not a zombie")
165 }166 }
@@ -231,18 +232,3 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
231 }232 }
232 require.Contains(t, acc, "GOTWINCH")233 require.Contains(t, acc, "GOTWINCH")
233 }234 }
234-
235-// itoa avoids importing strconv just for a couple call sites in this file.
236-func itoa(i int) string {
237- if i == 0 {
238- return "0"
239- }
240- var b [20]byte
241- pos := len(b)
242- for i > 0 {
243- pos--
244- b[pos] = byte('0' + i%10)
245- i /= 10
246- }
247- return string(b[pos:])
248-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close