Skip to content

Commit

Permalink
test: test TTY problems by fakeing a TTY using openpty
Browse files Browse the repository at this point in the history
Many thanks to thefourtheye and addaleax who helped make the python
bits of this possible.

See nodejs#6980 for more info regarding
the related TTY issues.

Refs: nodejs#6456
Refs: nodejs#6773
Refs: nodejs#6816
PR-URL: nodejs#6895
Reviewed-By: Rod Vagg <rod@vagg.org>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
  • Loading branch information
Fishrock123 committed Jun 1, 2016
1 parent 98de4ab commit 88804b8
Show file tree
Hide file tree
Showing 7 changed files with 293 additions and 14 deletions.
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ test: all
$(MAKE) build-addons
$(MAKE) cctest
$(PYTHON) tools/test.py --mode=release -J \
addon doctool known_issues message parallel sequential
addon doctool known_issues message pseudo-tty parallel sequential
$(MAKE) lint

test-parallel: all
Expand Down Expand Up @@ -183,7 +183,8 @@ test-all-valgrind: test-build
test-ci: | build-addons
$(PYTHON) tools/test.py $(PARALLEL_ARGS) -p tap --logfile test.tap \
--mode=release --flaky-tests=$(FLAKY_TESTS) \
$(TEST_CI_ARGS) addons doctool known_issues message parallel sequential
$(TEST_CI_ARGS) addons doctool known_issues message pseudo-tty parallel \
sequential

test-release: test-build
$(PYTHON) tools/test.py --mode=release
Expand Down
15 changes: 15 additions & 0 deletions test/pseudo-tty/no_dropped_stdio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// https://github.com/nodejs/node/issues/6456#issuecomment-219320599
// https://gist.github.com/isaacs/1495b91ec66b21d30b10572d72ad2cdd
'use strict';
require('../common');

// 1000 bytes wrapped at 50 columns
// \n turns into a double-byte character
// (48 + {2}) * 20 = 1000
var out = ('o'.repeat(48) + '\n').repeat(20);
// Add the remaining 24 bytes and terminate with an 'O'.
// This results in 1025 bytes, just enough to overflow the 1kb OS X TTY buffer.
out += 'o'.repeat(24) + 'O';

process.stdout.write(out);
process.exit(0);
21 changes: 21 additions & 0 deletions test/pseudo-tty/no_dropped_stdio.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
ooooooooooooooooooooooooO
17 changes: 17 additions & 0 deletions test/pseudo-tty/no_interleaved_stdio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// https://github.com/nodejs/node/issues/6456#issuecomment-219320599
// https://gist.github.com/isaacs/1495b91ec66b21d30b10572d72ad2cdd
'use strict';
require('../common');

// 1000 bytes wrapped at 50 columns
// \n turns into a double-byte character
// (48 + {2}) * 20 = 1000
var out = ('o'.repeat(48) + '\n').repeat(20);
// Add the remaining 24 bytes and terminate with an 'O'.
// This results in 1025 bytes, just enough to overflow the 1kb OS X TTY buffer.
out += 'o'.repeat(24) + 'O';

const err = '__This is some stderr__';

process.stdout.write(out);
process.stderr.write(err);
21 changes: 21 additions & 0 deletions test/pseudo-tty/no_interleaved_stdio.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
oooooooooooooooooooooooooooooooooooooooooooooooo
ooooooooooooooooooooooooO__This is some stderr__
161 changes: 161 additions & 0 deletions test/pseudo-tty/testcfg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Copyright 2008 the V8 project authors. All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
# * Neither the name of Google Inc. nor the names of its
# contributors may be used to endorse or promote products derived
# from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import test
import os
from os.path import join, exists, basename, isdir
import re
import utils

FLAGS_PATTERN = re.compile(r"//\s+Flags:(.*)")

class TTYTestCase(test.TestCase):

def __init__(self, path, file, expected, arch, mode, context, config):
super(TTYTestCase, self).__init__(context, path, arch, mode)
self.file = file
self.expected = expected
self.config = config
self.arch = arch
self.mode = mode

def IgnoreLine(self, str):
"""Ignore empty lines and valgrind output."""
if not str.strip(): return True
else: return str.startswith('==') or str.startswith('**')

def IsFailureOutput(self, output):
f = file(self.expected)
# Convert output lines to regexps that we can match
env = { 'basename': basename(self.file) }
patterns = [ ]
for line in f:
if not line.strip():
continue
pattern = re.escape(line.rstrip() % env)
pattern = pattern.replace('\\*', '.*')
pattern = '^%s$' % pattern
patterns.append(pattern)
# Compare actual output with the expected
raw_lines = (output.stdout + output.stderr).split('\n')
outlines = [ s.strip() for s in raw_lines if not self.IgnoreLine(s) ]
if len(outlines) != len(patterns):
print "length differs."
print "expect=%d" % len(patterns)
print "actual=%d" % len(outlines)
print "patterns:"
for i in xrange(len(patterns)):
print "pattern = %s" % patterns[i]
print "outlines:"
for i in xrange(len(outlines)):
print "outline = %s" % outlines[i]
return True
for i in xrange(len(patterns)):
if not re.match(patterns[i], outlines[i]):
print "match failed"
print "line=%d" % i
print "expect=%s" % patterns[i]
print "actual=%s" % outlines[i]
return True
return False

def GetLabel(self):
return "%s %s" % (self.mode, self.GetName())

def GetName(self):
return self.path[-1]

def GetCommand(self):
result = [self.config.context.GetVm(self.arch, self.mode)]
source = open(self.file).read()
flags_match = FLAGS_PATTERN.search(source)
if flags_match:
result += flags_match.group(1).strip().split()
result.append(self.file)
return result

def GetSource(self):
return (open(self.file).read()
+ "\n--- expected output ---\n"
+ open(self.expected).read())

def RunCommand(self, command, env):
full_command = self.context.processor(command)
output = test.Execute(full_command,
self.context,
self.context.GetTimeout(self.mode),
env,
True)
self.Cleanup()
return test.TestOutput(self,
full_command,
output,
self.context.store_unexpected_output)


class TTYTestConfiguration(test.TestConfiguration):

def __init__(self, context, root):
super(TTYTestConfiguration, self).__init__(context, root)

def Ls(self, path):
if isdir(path):
return [f[:-3] for f in os.listdir(path) if f.endswith('.js')]
else:
return []

def ListTests(self, current_path, path, arch, mode):
all_tests = [current_path + [t] for t in self.Ls(self.root)]
result = []
# Skip these tests on Windows, as pseudo terminals are not available
if utils.IsWindows():
print ("Skipping pseudo-tty tests, as pseudo terminals are not available"
" on Windows.")
return result
for test in all_tests:
if self.Contains(path, test):
file_prefix = join(self.root, reduce(join, test[1:], ""))
file_path = file_prefix + ".js"
output_path = file_prefix + ".out"
if not exists(output_path):
print "Could not find %s" % output_path
continue
result.append(TTYTestCase(test, file_path, output_path,
arch, mode, self.context, self))
return result

def GetBuildRequirements(self):
return ['sample', 'sample=shell']

def GetTestStatus(self, sections, defs):
status_file = join(self.root, 'message.status')
if exists(status_file):
test.ReadConfigurationInto(status_file, sections, defs)


def GetConfiguration(context, root):
return TTYTestConfiguration(context, root)
67 changes: 55 additions & 12 deletions tools/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,11 +576,17 @@ def RunProcess(context, timeout, args, **rest):
error_mode = SEM_NOGPFAULTERRORBOX;
prev_error_mode = Win32SetErrorMode(error_mode);
Win32SetErrorMode(error_mode | prev_error_mode);

faketty = rest.pop('faketty', False)
pty_out = rest.pop('pty_out')

process = subprocess.Popen(
shell = utils.IsWindows(),
args = popen_args,
**rest
)
if faketty:
os.close(rest['stdout'])
if utils.IsWindows() and context.suppress_dialogs and prev_error_mode != SEM_INVALID_VALUE:
Win32SetErrorMode(prev_error_mode)
# Compute the end time - if the process crosses this limit we
Expand All @@ -592,6 +598,29 @@ def RunProcess(context, timeout, args, **rest):
# loop and keep track of whether or not it times out.
exit_code = None
sleep_time = INITIAL_SLEEP_TIME
output = ''
if faketty:
while True:
if time.time() >= end_time:
# Kill the process and wait for it to exit.
KillProcessWithID(process.pid)
exit_code = process.wait()
timed_out = True
break

# source: http://stackoverflow.com/a/12471855/1903116
# related: http://stackoverflow.com/q/11165521/1903116
try:
data = os.read(pty_out, 9999)
except OSError as e:
if e.errno != errno.EIO:
raise
break # EIO means EOF on some systems
else:
if not data: # EOF
break
output += data

while exit_code is None:
if (not end_time is None) and (time.time() >= end_time):
# Kill the process and wait for it to exit.
Expand All @@ -604,7 +633,7 @@ def RunProcess(context, timeout, args, **rest):
sleep_time = sleep_time * SLEEP_TIME_FACTOR
if sleep_time > MAX_SLEEP_TIME:
sleep_time = MAX_SLEEP_TIME
return (process, exit_code, timed_out)
return (process, exit_code, timed_out, output)


def PrintError(str):
Expand All @@ -626,29 +655,43 @@ def CheckedUnlink(name):
PrintError("os.unlink() " + str(e))
break

def Execute(args, context, timeout=None, env={}):
(fd_out, outname) = tempfile.mkstemp()
(fd_err, errname) = tempfile.mkstemp()
def Execute(args, context, timeout=None, env={}, faketty=False):
if faketty:
import pty
(out_master, fd_out) = pty.openpty()
fd_err = fd_out
pty_out = out_master
else:
(fd_out, outname) = tempfile.mkstemp()
(fd_err, errname) = tempfile.mkstemp()
pty_out = None

# Extend environment
env_copy = os.environ.copy()
for key, value in env.iteritems():
env_copy[key] = value

(process, exit_code, timed_out) = RunProcess(
(process, exit_code, timed_out, output) = RunProcess(
context,
timeout,
args = args,
stdout = fd_out,
stderr = fd_err,
env = env_copy
env = env_copy,
faketty = faketty,
pty_out = pty_out
)
os.close(fd_out)
os.close(fd_err)
output = file(outname).read()
errors = file(errname).read()
CheckedUnlink(outname)
CheckedUnlink(errname)
if faketty:
os.close(out_master)
errors = ''
else:
os.close(fd_out)
os.close(fd_err)
output = file(outname).read()
errors = file(errname).read()
CheckedUnlink(outname)
CheckedUnlink(errname)

return CommandOutput(exit_code, timed_out, output, errors)


Expand Down

0 comments on commit 88804b8

Please sign in to comment.