Skip to content
Merged
9 changes: 6 additions & 3 deletions Lib/subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -1307,15 +1307,18 @@ def _execute_child(self, args, executable, preexec_fn, close_fds,
try:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To simplify the code, I suggest to decode pipe data before the try:

errpipe_data = errpipe_data.decode(errors="surrogatepass")

Since both code paths decode anyway. It would allow to work on Unicode rather than bytes, which is more convenient.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But decoding can fail itself.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But decoding can fail itself.

I don't see how .decode(errors="surrogatepass") can fail. MemoryError, maybe? If you are out of memory, everything will fail anyway :-)

See the current code, we already decode, I only suggest to move the code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example:

b"\xff".decode(errors="surrogatepass")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@serhiy-storchaka, @ammaraskar: Oh sorry, I was thinking at surrogateescape, whereas it's surrogatepeass. Please replace .decode('surrogatepass') with .decode('surrogateescape'), so decoding cannot fail.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you read my comment here? #3066 (comment)

In the case where _posixsubprocess writes to the errpipe, there should be nothing in there that can't be decoded. And if there is junk in there that wasn't written by _posixsubproccess, the decode will fail and it'll print out the appropriate message saying Bad exception data from child: b'UNDECODABLE:MESS:HERE'

exception_name, hex_errno, err_msg = (
errpipe_data.split(b':', 2))
# The encoding here should match the encoding
# written in by the subprocess implementations
# like _posixsubprocess
err_msg = err_msg.decode()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep .decode(errors="surrogatepass"). The modified code is called when something goes wrong. I would prefer to avoid a decoding error if possible. It can happen that something writes junk into the pipe.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why you expect that this junk is UTF-8 with allowed surrogates?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm not sure where you're expecting this junk from. If the split managed to succeed then the data is likely written in by _posixsubprocess here https://github.com/python/cpython/blob/master/Modules/_posixsubprocess.c#L522
If it is then there wouldn't be any extra junk and so the decode should never fail unless the junk somehow manages to contain 2 colon characters by accident.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless the junk somehow manages to contain 2 colon characters by accident

I've added UnicodeError to the except block there, so in case this actually happens it'll fall through and simply put the repr of the bytes in the error message.

except ValueError:
exception_name = b'SubprocessError'
hex_errno = b'0'
err_msg = (b'Bad exception data from child: ' +
repr(errpipe_data))
err_msg = 'Bad exception data from child: {!r}'.format(
bytes(errpipe_data))
child_exception_type = getattr(
builtins, exception_name.decode('ascii'),
SubprocessError)
err_msg = err_msg.decode(errors="surrogatepass")
if issubclass(child_exception_type, OSError) and hex_errno:
errno_num = int(hex_errno, 16)
child_exec_never_called = (err_msg == "noexec")
Expand Down
47 changes: 47 additions & 0 deletions Lib/test/test_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,53 @@ def test_exception_bad_args_0(self):
else:
self.fail("Expected OSError: %s" % desired_exception)

# We mock the __del__ method for Popen in the next two tests
# because it does cleanup based on the pid returned by fork_exec
# along with issuing a resource warning if it still exists. Since
# we don't actually spawn a process in these tests we can forego
# the destructor. An alternative would be to set _child_created to
# False before the destructor is called but there is no easy way
# to do that
class PopenNoDestructor(subprocess.Popen):
def __del__(self):
pass

@mock.patch("subprocess._posixsubprocess.fork_exec")
def test_exception_errpipe_normal(self, fork_exec):
"""Test error passing done through errpipe_write in the good case"""
def proper_error(*args):
errpipe_write = args[13]
# Write the hex for the error code EISDIR: 'is a directory'
err_code = '{:x}'.format(errno.EISDIR).encode()
os.write(errpipe_write, b"OSError:" + err_code + b":")
return 0

fork_exec.side_effect = proper_error

with self.assertRaises(IsADirectoryError):
self.PopenNoDestructor(["non_existent_command"])

@mock.patch("subprocess._posixsubprocess.fork_exec")
def test_exception_errpipe_bad_data(self, fork_exec):
"""Test error passing done through errpipe_write where its not
in the expected format"""
error_data = b"\xFF\x00\xDE\xAD"
def bad_error(*args):
errpipe_write = args[13]
# Anything can be in the pipe, no assumptions should
# be made about its encoding, so we'll write some
# arbitrary hex bytes to test it out
os.write(errpipe_write, error_data)
return 0

fork_exec.side_effect = bad_error

with self.assertRaises(subprocess.SubprocessError) as e:
self.PopenNoDestructor(["non_existent_command"])

self.assertIn(repr(error_data), str(e.exception))


def test_restore_signals(self):
# Code coverage for both values of restore_signals to make sure it
# at least does not blow up.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix string concatenation bug in rare error path in the subprocess module