Skip to content

Commit ada0ed5

Browse files
bnoordhuisBethGriggs
authored andcommitted
test: fix pty test hangs on aix
Some pty tests persistently hung on the AIX CI buildbots. Fix that by adding a helper script that properly sets up the pty before spawning the script under test. On investigation I discovered that the test runner hung when it tried to close the slave pty's file descriptor, probably due to a bug in AIX's pty implementation. I could reproduce it with a short C program. The test runner also leaked file descriptors to the child process. I couldn't convince python's `subprocess.Popen()` to do what I wanted it to do so I opted to move the logic to a helper script that can do fork/setsid/etc. without having to worry about stomping on state in tools/test.py. In the process I also uncovered some bugs in the pty module of the python distro that ships with macOS 10.14, leading me to reimplement a sizable chunk of the functionality of that module. And last but not least, of course there are differences between ptys on different platforms and the helper script has to paper over that. Of course. Really, this commit took me longer to put together than I care to admit. Caveat emptor: this commit takes the hacky ^D feeding to the slave out of tools/test.py and puts it in the *.in input files. You can also feed other control characters to tests, like ^C or ^Z, simply by inserting them into the corresponding input file. I think that's nice. Fixes: nodejs/build#1820 Fixes: #28489 PR-URL: #28600 Backport-PR-URL: #28826 Reviewed-By: Richard Lau <riclau@uk.ibm.com> Reviewed-By: Sam Roberts <vieuxtech@gmail.com> Reviewed-By: Anna Henningsen <anna@addaleax.net>
1 parent 2ae9916 commit ada0ed5

File tree

6 files changed

+123
-77
lines changed

6 files changed

+123
-77
lines changed

test/pseudo-tty/pseudo-tty.status

-8
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
prefix pseudo-tty
22

3-
[$system==aix]
4-
# being investigated under https://github.com/nodejs/node/issues/9728
5-
test-tty-wrap : FAIL, PASS
6-
# https://github.com/nodejs/build/issues/1820#issuecomment-505998851
7-
# https://github.com/nodejs/node/pull/28469
8-
console-dumb-tty: SKIP
9-
test-fatal-error: SKIP
10-
113
[$system==solaris]
124
# https://github.com/nodejs/node/pull/16225 - `ioctl(fd, TIOCGWINSZ)` seems
135
# to fail with EINVAL on SmartOS when `fd` is a pty from python's pty module.

test/pseudo-tty/pty_helper.py

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import errno
2+
import os
3+
import pty
4+
import select
5+
import signal
6+
import sys
7+
import termios
8+
9+
STDIN = 0
10+
STDOUT = 1
11+
STDERR = 2
12+
13+
14+
def pipe(sfd, dfd):
15+
try:
16+
data = os.read(sfd, 256)
17+
except OSError as e:
18+
if e.errno != errno.EIO:
19+
raise
20+
return True # EOF
21+
22+
if not data:
23+
return True # EOF
24+
25+
if dfd == STDOUT:
26+
# Work around platform quirks. Some platforms echo ^D as \x04
27+
# (AIX, BSDs) and some don't (Linux).
28+
filt = lambda c: ord(c) > 31 or c in '\t\n\r\f'
29+
data = filter(filt, data)
30+
31+
while data:
32+
try:
33+
n = os.write(dfd, data)
34+
except OSError as e:
35+
if e.errno != errno.EIO:
36+
raise
37+
return True # EOF
38+
data = data[n:]
39+
40+
41+
if __name__ == '__main__':
42+
argv = sys.argv[1:]
43+
44+
# Make select() interruptable by SIGCHLD.
45+
signal.signal(signal.SIGCHLD, lambda nr, _: None)
46+
47+
master_fd, slave_fd = pty.openpty()
48+
assert master_fd > STDIN
49+
50+
mode = termios.tcgetattr(slave_fd)
51+
# Don't translate \n to \r\n.
52+
mode[1] = mode[1] & ~termios.ONLCR # oflag
53+
# Disable ECHOCTL. It's a BSD-ism that echoes e.g. \x04 as ^D but it
54+
# doesn't work on platforms like AIX and Linux. I checked Linux's tty
55+
# driver and it's a no-op, the driver is just oblivious to the flag.
56+
mode[3] = mode[3] & ~termios.ECHOCTL # lflag
57+
termios.tcsetattr(slave_fd, termios.TCSANOW, mode)
58+
59+
pid = os.fork()
60+
if not pid:
61+
os.setsid()
62+
os.close(master_fd)
63+
64+
# Ensure the pty is a controlling tty.
65+
name = os.ttyname(slave_fd)
66+
fd = os.open(name, os.O_RDWR)
67+
os.dup2(fd, slave_fd)
68+
os.close(fd)
69+
70+
os.dup2(slave_fd, STDIN)
71+
os.dup2(slave_fd, STDOUT)
72+
os.dup2(slave_fd, STDERR)
73+
74+
if slave_fd > STDERR:
75+
os.close(slave_fd)
76+
77+
os.execve(argv[0], argv, os.environ)
78+
raise Exception('unreachable')
79+
80+
os.close(slave_fd)
81+
82+
fds = [STDIN, master_fd]
83+
while fds:
84+
try:
85+
rfds, _, _ = select.select(fds, [], [])
86+
except select.error as e:
87+
if e[0] != errno.EINTR:
88+
raise
89+
if pid == os.waitpid(pid, os.WNOHANG)[0]:
90+
break
91+
92+
if STDIN in rfds:
93+
if pipe(STDIN, master_fd):
94+
fds.remove(STDIN)
95+
96+
if master_fd in rfds:
97+
if pipe(master_fd, STDOUT):
98+
break

test/pseudo-tty/test-stdout-read.in

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
Hello!
2+


test/pseudo-tty/test-stdout-read.out

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
Hello!
12
<Buffer 48 65 6c 6c 6f 21 0a>

test/pseudo-tty/testcfg.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929

3030
import test
3131
import os
32-
from os.path import join, exists, basename, isdir
32+
from os.path import join, exists, basename, dirname, isdir
3333
import re
34+
import sys
3435
import utils
3536

3637
try:
@@ -44,6 +45,7 @@
4445
xrange = range # Python 3
4546

4647
FLAGS_PATTERN = re.compile(r"//\s+Flags:(.*)")
48+
PTY_HELPER = join(dirname(__file__), 'pty_helper.py')
4749

4850
class TTYTestCase(test.TestCase):
4951

@@ -117,16 +119,18 @@ def GetSource(self):
117119
+ open(self.expected).read())
118120

119121
def RunCommand(self, command, env):
120-
input = None
122+
fd = None
121123
if self.input is not None and exists(self.input):
122-
input = open(self.input).read()
124+
fd = os.open(self.input, os.O_RDONLY)
123125
full_command = self.context.processor(command)
126+
full_command = [sys.executable, PTY_HELPER] + full_command
124127
output = test.Execute(full_command,
125128
self.context,
126129
self.context.GetTimeout(self.mode),
127130
env,
128-
faketty=True,
129-
input=input)
131+
stdin=fd)
132+
if fd is not None:
133+
os.close(fd)
130134
return test.TestOutput(self,
131135
full_command,
132136
output,

tools/test.py

+14-64
Original file line numberDiff line numberDiff line change
@@ -648,15 +648,10 @@ def RunProcess(context, timeout, args, **rest):
648648
prev_error_mode = Win32SetErrorMode(error_mode)
649649
Win32SetErrorMode(error_mode | prev_error_mode)
650650

651-
faketty = rest.pop('faketty', False)
652-
pty_out = rest.pop('pty_out')
653-
654651
process = subprocess.Popen(
655652
args = popen_args,
656653
**rest
657654
)
658-
if faketty:
659-
os.close(rest['stdout'])
660655
if utils.IsWindows() and context.suppress_dialogs and prev_error_mode != SEM_INVALID_VALUE:
661656
Win32SetErrorMode(prev_error_mode)
662657
# Compute the end time - if the process crosses this limit we
@@ -668,28 +663,6 @@ def RunProcess(context, timeout, args, **rest):
668663
# loop and keep track of whether or not it times out.
669664
exit_code = None
670665
sleep_time = INITIAL_SLEEP_TIME
671-
output = ''
672-
if faketty:
673-
while True:
674-
if time.time() >= end_time:
675-
# Kill the process and wait for it to exit.
676-
KillTimedOutProcess(context, process.pid)
677-
exit_code = process.wait()
678-
timed_out = True
679-
break
680-
681-
# source: http://stackoverflow.com/a/12471855/1903116
682-
# related: http://stackoverflow.com/q/11165521/1903116
683-
try:
684-
data = os.read(pty_out, 9999)
685-
except OSError as e:
686-
if e.errno != errno.EIO:
687-
raise
688-
break # EIO means EOF on some systems
689-
else:
690-
if not data: # EOF
691-
break
692-
output += data
693666

694667
while exit_code is None:
695668
if (not end_time is None) and (time.time() >= end_time):
@@ -703,7 +676,7 @@ def RunProcess(context, timeout, args, **rest):
703676
sleep_time = sleep_time * SLEEP_TIME_FACTOR
704677
if sleep_time > MAX_SLEEP_TIME:
705678
sleep_time = MAX_SLEEP_TIME
706-
return (process, exit_code, timed_out, output)
679+
return (process, exit_code, timed_out)
707680

708681

709682
def PrintError(str):
@@ -725,29 +698,12 @@ def CheckedUnlink(name):
725698
PrintError("os.unlink() " + str(e))
726699
break
727700

728-
def Execute(args, context, timeout=None, env={}, faketty=False, disable_core_files=False, input=None):
729-
if faketty:
730-
import pty
731-
(out_master, fd_out) = pty.openpty()
732-
fd_in = fd_err = fd_out
733-
pty_out = out_master
734-
735-
if input is not None:
736-
# Before writing input data, disable echo so the input doesn't show
737-
# up as part of the output.
738-
import termios
739-
attr = termios.tcgetattr(fd_in)
740-
attr[3] = attr[3] & ~termios.ECHO
741-
termios.tcsetattr(fd_in, termios.TCSADRAIN, attr)
742-
743-
os.write(pty_out, input)
744-
os.write(pty_out, '\x04') # End-of-file marker (Ctrl+D)
745-
else:
746-
(fd_out, outname) = tempfile.mkstemp()
747-
(fd_err, errname) = tempfile.mkstemp()
748-
fd_in = 0
749-
pty_out = None
701+
def Execute(args, context, timeout=None, env=None, disable_core_files=False, stdin=None):
702+
(fd_out, outname) = tempfile.mkstemp()
703+
(fd_err, errname) = tempfile.mkstemp()
750704

705+
if env is None:
706+
env = {}
751707
env_copy = os.environ.copy()
752708

753709
# Remove NODE_PATH
@@ -766,28 +722,22 @@ def disableCoreFiles():
766722
resource.setrlimit(resource.RLIMIT_CORE, (0,0))
767723
preexec_fn = disableCoreFiles
768724

769-
(process, exit_code, timed_out, output) = RunProcess(
725+
(process, exit_code, timed_out) = RunProcess(
770726
context,
771727
timeout,
772728
args = args,
773-
stdin = fd_in,
729+
stdin = stdin,
774730
stdout = fd_out,
775731
stderr = fd_err,
776732
env = env_copy,
777-
faketty = faketty,
778-
pty_out = pty_out,
779733
preexec_fn = preexec_fn
780734
)
781-
if faketty:
782-
os.close(out_master)
783-
errors = ''
784-
else:
785-
os.close(fd_out)
786-
os.close(fd_err)
787-
output = open(outname).read()
788-
errors = open(errname).read()
789-
CheckedUnlink(outname)
790-
CheckedUnlink(errname)
735+
os.close(fd_out)
736+
os.close(fd_err)
737+
output = open(outname).read()
738+
errors = open(errname).read()
739+
CheckedUnlink(outname)
740+
CheckedUnlink(errname)
791741

792742
return CommandOutput(exit_code, timed_out, output, errors)
793743

0 commit comments

Comments
 (0)