fix(pty): resize via raw master fd — keep poller registration
- commit
- ffe9b7ef9dcd00a1d042c0bf2b60e75e7567da50
- author
- brice · · 2026-07-02 11:06 UTC
- parent
- 7ac6830
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 108 | 108 | master *os.File | |
| 109 | 109 | cmd *exec.Cmd | |
| 110 | 110 | ||
| 111 | - | waitOnce sync.Once | |
| 112 | - | waitErr error | |
| 113 | - | ||
| 114 | 111 | closeOnce sync.Once | |
| 115 | 112 | closeErr error | |
| 116 | 113 | } | |
| @@ -137,8 +134,24 @@ func (p *linuxPTY) Write(b []byte) (int, error) { | |||
| 137 | 134 | ||
| 138 | 135 | // Resize updates the terminal window size, which also delivers SIGWINCH to the | |
| 139 | 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 | 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 | 157 | // Pid reports the child process id. | |
| @@ -159,9 +172,11 @@ func (p *linuxPTY) Close() error { | |||
| 159 | 172 | // leader thanks to Setsid). Ignore errors: the child may already be gone. | |
| 160 | 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 | 177 | reaped := make(chan struct{}) | |
| 163 | 178 | go func() { | |
| 164 | - | p.wait() | |
| 179 | + | _ = p.cmd.Wait() | |
| 165 | 180 | close(reaped) | |
| 166 | 181 | }() | |
| 167 | 182 | ||
| @@ -177,12 +192,6 @@ func (p *linuxPTY) Close() error { | |||
| 177 | 192 | 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 | 195 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |
| 187 | 196 | func setWinsize(fd int, sz domain.Size) error { | |
| 188 | 197 | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | |
| @@ -108,9 +108,6 @@ type linuxPTY struct { | |||||
| 108 | master *os.File | 108 | master *os.File | ||
| 109 | cmd *exec.Cmd | 109 | cmd *exec.Cmd | ||
| 110 | 110 | ||||
| 111 | - | waitOnce sync.Once | |||
| 112 | - | waitErr error | |||
| 113 | - | ||||
| 114 | closeOnce sync.Once | 111 | closeOnce sync.Once | ||
| 115 | closeErr error | 112 | 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 the | 135 | // 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.closeErr | 192 | 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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | + | "strconv" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "syscall" | |
| 12 | 13 | "testing" | |
| @@ -159,7 +160,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) { | |||
| 159 | 160 | err = syscall.Kill(pid, 0) | |
| 160 | 161 | if err == nil { | |
| 161 | 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 | 164 | if rerr == nil { | |
| 164 | 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 | 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 | - | } | |
| @@ -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 | - | } | |||