Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1f7fb15

Browse files
RadicalZephyrmiss-islington
authored andcommittedAug 11, 2021
bpo-26228: Fix pty EOF handling (pythonGH-12049)
On non-Linux POSIX platforms, like FreeBSD or macOS, the FD used to read a forked PTY may signal its exit not by raising an error but by sending empty data to the read syscall. This case wasn't handled, leading to hanging `pty.spawn` calls. Co-authored-by: Reilly Tucker Siemens <reilly@tuckersiemens.com> Co-authored-by: Łukasz Langa <lukasz@langa.pl> (cherry picked from commit 81ab8db) Co-authored-by: Zephyr Shannon <geoffpshannon@gmail.com>
1 parent 2666d70 commit 1f7fb15

File tree

5 files changed

+85
-37
lines changed

5 files changed

+85
-37
lines changed
 

‎Doc/library/pty.rst

+1-5
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The :mod:`pty` module defines the following functions:
5050
The functions *master_read* and *stdin_read* are passed a file descriptor
5151
which they should read from, and they should always return a byte string. In
5252
order to force spawn to return before the child process exits an
53-
:exc:`OSError` should be thrown.
53+
empty byte array should be returned to signal end of file.
5454

5555
The default implementation for both functions will read and return up to 1024
5656
bytes each time the function is called. The *master_read* callback is passed
@@ -65,10 +65,6 @@ The :mod:`pty` module defines the following functions:
6565
process will quit without any input, *spawn* will then loop forever. If
6666
*master_read* signals EOF the same behavior results (on linux at least).
6767

68-
If both callbacks signal EOF then *spawn* will probably never return, unless
69-
*select* throws an error on your platform when passed three empty lists. This
70-
is a bug, documented in `issue 26228 <https://bugs.python.org/issue26228>`_.
71-
7268
Return the exit status value from :func:`os.waitpid` on the child process.
7369

7470
:func:`waitstatus_to_exitcode` can be used to convert the exit status into

‎Lib/pty.py

+30-15
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
import sys
1212
import tty
1313

14-
__all__ = ["openpty","fork","spawn"]
14+
# names imported directly for test mocking purposes
15+
from os import close, waitpid
16+
from tty import setraw, tcgetattr, tcsetattr
17+
18+
__all__ = ["openpty", "fork", "spawn"]
1519

1620
STDIN_FILENO = 0
1721
STDOUT_FILENO = 1
@@ -105,8 +109,8 @@ def fork():
105109
os.dup2(slave_fd, STDIN_FILENO)
106110
os.dup2(slave_fd, STDOUT_FILENO)
107111
os.dup2(slave_fd, STDERR_FILENO)
108-
if (slave_fd > STDERR_FILENO):
109-
os.close (slave_fd)
112+
if slave_fd > STDERR_FILENO:
113+
os.close(slave_fd)
110114

111115
# Explicitly open the tty to make it become a controlling tty.
112116
tmp_fd = os.open(os.ttyname(STDOUT_FILENO), os.O_RDWR)
@@ -133,14 +137,22 @@ def _copy(master_fd, master_read=_read, stdin_read=_read):
133137
pty master -> standard output (master_read)
134138
standard input -> pty master (stdin_read)"""
135139
fds = [master_fd, STDIN_FILENO]
136-
while True:
137-
rfds, wfds, xfds = select(fds, [], [])
140+
while fds:
141+
rfds, _wfds, _xfds = select(fds, [], [])
142+
138143
if master_fd in rfds:
139-
data = master_read(master_fd)
144+
# Some OSes signal EOF by returning an empty byte string,
145+
# some throw OSErrors.
146+
try:
147+
data = master_read(master_fd)
148+
except OSError:
149+
data = b""
140150
if not data: # Reached EOF.
141-
fds.remove(master_fd)
151+
return # Assume the child process has exited and is
152+
# unreachable, so we clean up.
142153
else:
143154
os.write(STDOUT_FILENO, data)
155+
144156
if STDIN_FILENO in rfds:
145157
data = stdin_read(STDIN_FILENO)
146158
if not data:
@@ -153,20 +165,23 @@ def spawn(argv, master_read=_read, stdin_read=_read):
153165
if type(argv) == type(''):
154166
argv = (argv,)
155167
sys.audit('pty.spawn', argv)
168+
156169
pid, master_fd = fork()
157170
if pid == CHILD:
158171
os.execlp(argv[0], *argv)
172+
159173
try:
160-
mode = tty.tcgetattr(STDIN_FILENO)
161-
tty.setraw(STDIN_FILENO)
162-
restore = 1
174+
mode = tcgetattr(STDIN_FILENO)
175+
setraw(STDIN_FILENO)
176+
restore = True
163177
except tty.error: # This is the same as termios.error
164-
restore = 0
178+
restore = False
179+
165180
try:
166181
_copy(master_fd, master_read, stdin_read)
167-
except OSError:
182+
finally:
168183
if restore:
169-
tty.tcsetattr(STDIN_FILENO, tty.TCSAFLUSH, mode)
184+
tcsetattr(STDIN_FILENO, tty.TCSAFLUSH, mode)
170185

171-
os.close(master_fd)
172-
return os.waitpid(pid, 0)[1]
186+
close(master_fd)
187+
return waitpid(pid, 0)[1]

‎Lib/test/test_pty.py

+52-17
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import_module('termios')
66

77
import errno
8-
import pty
98
import os
9+
import pty
10+
import tty
1011
import sys
1112
import select
1213
import signal
@@ -123,12 +124,6 @@ def handle_sig(self, sig, frame):
123124

124125
@staticmethod
125126
def handle_sighup(signum, frame):
126-
# bpo-38547: if the process is the session leader, os.close(master_fd)
127-
# of "master_fd, slave_name = pty.master_open()" raises SIGHUP
128-
# signal: just ignore the signal.
129-
#
130-
# NOTE: the above comment is from an older version of the test;
131-
# master_open() is not being used anymore.
132127
pass
133128

134129
@expectedFailureIfStdinIsTTY
@@ -190,13 +185,6 @@ def test_openpty(self):
190185
self.assertEqual(_get_term_winsz(slave_fd), new_stdin_winsz,
191186
"openpty() failed to set slave window size")
192187

193-
# Solaris requires reading the fd before anything is returned.
194-
# My guess is that since we open and close the slave fd
195-
# in master_open(), we need to read the EOF.
196-
#
197-
# NOTE: the above comment is from an older version of the test;
198-
# master_open() is not being used anymore.
199-
200188
# Ensure the fd is non-blocking in case there's nothing to read.
201189
blocking = os.get_blocking(master_fd)
202190
try:
@@ -324,22 +312,40 @@ def test_master_read(self):
324312

325313
self.assertEqual(data, b"")
326314

315+
def test_spawn_doesnt_hang(self):
316+
pty.spawn([sys.executable, '-c', 'print("hi there")'])
317+
327318
class SmallPtyTests(unittest.TestCase):
328319
"""These tests don't spawn children or hang."""
329320

330321
def setUp(self):
331322
self.orig_stdin_fileno = pty.STDIN_FILENO
332323
self.orig_stdout_fileno = pty.STDOUT_FILENO
324+
self.orig_pty_close = pty.close
325+
self.orig_pty__copy = pty._copy
326+
self.orig_pty_fork = pty.fork
333327
self.orig_pty_select = pty.select
328+
self.orig_pty_setraw = pty.setraw
329+
self.orig_pty_tcgetattr = pty.tcgetattr
330+
self.orig_pty_tcsetattr = pty.tcsetattr
331+
self.orig_pty_waitpid = pty.waitpid
334332
self.fds = [] # A list of file descriptors to close.
335333
self.files = []
336334
self.select_rfds_lengths = []
337335
self.select_rfds_results = []
336+
self.tcsetattr_mode_setting = None
338337

339338
def tearDown(self):
340339
pty.STDIN_FILENO = self.orig_stdin_fileno
341340
pty.STDOUT_FILENO = self.orig_stdout_fileno
341+
pty.close = self.orig_pty_close
342+
pty._copy = self.orig_pty__copy
343+
pty.fork = self.orig_pty_fork
342344
pty.select = self.orig_pty_select
345+
pty.setraw = self.orig_pty_setraw
346+
pty.tcgetattr = self.orig_pty_tcgetattr
347+
pty.tcsetattr = self.orig_pty_tcsetattr
348+
pty.waitpid = self.orig_pty_waitpid
343349
for file in self.files:
344350
try:
345351
file.close()
@@ -367,6 +373,14 @@ def _mock_select(self, rfds, wfds, xfds, timeout=0):
367373
self.assertEqual(self.select_rfds_lengths.pop(0), len(rfds))
368374
return self.select_rfds_results.pop(0), [], []
369375

376+
def _make_mock_fork(self, pid):
377+
def mock_fork():
378+
return (pid, 12)
379+
return mock_fork
380+
381+
def _mock_tcsetattr(self, fileno, opt, mode):
382+
self.tcsetattr_mode_setting = mode
383+
370384
def test__copy_to_each(self):
371385
"""Test the normal data case on both master_fd and stdin."""
372386
read_from_stdout_fd, mock_stdout_fd = self._pipe()
@@ -407,20 +421,41 @@ def test__copy_eof_on_all(self):
407421
socketpair[1].close()
408422
os.close(write_to_stdin_fd)
409423

410-
# Expect two select calls, the last one will cause IndexError
411424
pty.select = self._mock_select
412425
self.select_rfds_lengths.append(2)
413426
self.select_rfds_results.append([mock_stdin_fd, masters[0]])
414427
# We expect that both fds were removed from the fds list as they
415428
# both encountered an EOF before the second select call.
416429
self.select_rfds_lengths.append(0)
417430

418-
with self.assertRaises(IndexError):
419-
pty._copy(masters[0])
431+
# We expect the function to return without error.
432+
self.assertEqual(pty._copy(masters[0]), None)
433+
434+
def test__restore_tty_mode_normal_return(self):
435+
"""Test that spawn resets the tty mode no when _copy returns normally."""
436+
437+
# PID 1 is returned from mocked fork to run the parent branch
438+
# of code
439+
pty.fork = self._make_mock_fork(1)
440+
441+
status_sentinel = object()
442+
pty.waitpid = lambda _1, _2: [None, status_sentinel]
443+
pty.close = lambda _: None
444+
445+
pty._copy = lambda _1, _2, _3: None
446+
447+
mode_sentinel = object()
448+
pty.tcgetattr = lambda fd: mode_sentinel
449+
pty.tcsetattr = self._mock_tcsetattr
450+
pty.setraw = lambda _: None
451+
452+
self.assertEqual(pty.spawn([]), status_sentinel, "pty.waitpid process status not returned by pty.spawn")
453+
self.assertEqual(self.tcsetattr_mode_setting, mode_sentinel, "pty.tcsetattr not called with original mode value")
420454

421455

422456
def tearDownModule():
423457
reap_children()
424458

459+
425460
if __name__ == "__main__":
426461
unittest.main()

‎Misc/ACKS

+1
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,7 @@ Yue Shuaijie
16211621
Jaysinh Shukla
16221622
Terrel Shumway
16231623
Eric Siegerman
1624+
Reilly Tucker Siemens
16241625
Paul Sijben
16251626
SilentGhost
16261627
Tim Silk
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pty.spawn no longer hangs on FreeBSD, OS X, and Solaris.

0 commit comments

Comments
 (0)
Please sign in to comment.