Handle None return from read() in run_command() (#86922) (#86932)

(cherry picked from commit 776f90ae4b)
This commit is contained in:
David Shrewsbury
2026-06-08 15:31:20 -04:00
committed by GitHub
parent b45455e3f0
commit 3d9229fdf3
3 changed files with 87 additions and 1 deletions
@@ -0,0 +1,2 @@
bugfixes:
- module_utils/basic.py - Fix ``AnsibleModule.run_command()`` to handle ``None`` return from non-blocking pipe reads (https://github.com/ansible/ansible/issues/86920).
+7 -1
View File
@@ -2092,7 +2092,13 @@ class AnsibleModule(object):
stdout_changed = False
for key, event in events:
b_chunk = key.fileobj.read(32768)
if not b_chunk and b_chunk is not None:
if b_chunk is None:
# Non-blocking read returned None (no data currently available).
# This can happen with certain file-like objects or in edge cases.
# Skip this chunk and try again on next select iteration.
continue
if not b_chunk:
# Empty bytes received, EOF reached
selector.unregister(key.fileobj)
elif key.fileobj == cmd.stdout:
stdout += b_chunk
@@ -255,3 +255,81 @@ def test_run_command_fds(mocker, rc_am):
assert subprocess_mock.Popen.call_args[1]['pass_fds'] == (101, 42)
assert subprocess_mock.Popen.call_args[1]['close_fds'] is True
class TestRunCommandNoneRead:
"""
Test handling of read() returning None from non-blocking pipes.
This tests the fix for issue #86920 where read() can return None
in certain edge cases with non-blocking I/O, which would cause
TypeError when trying to concatenate None to bytes.
"""
class NoneReturningBytesIO(SpecialBytesIO):
"""
BytesIO that returns None on first read, then actual data.
This simulates edge cases where non-blocking read() returns None
to indicate "no data available right now" rather than empty bytes.
"""
def __init__(self, *args, **kwargs):
# Pop 'data' before calling super().__init__() since BytesIO doesn't accept it
self.data = kwargs.pop('data', b'test output')
self.read_count = 0
super(TestRunCommandNoneRead.NoneReturningBytesIO, self).__init__(*args, **kwargs)
def read(self, size=-1):
self.read_count += 1
if self.read_count == 1:
# First read returns None (no data available)
return None
elif self.read_count == 2:
# Second read returns actual data
return self.data
else:
# Subsequent reads return empty bytes (EOF)
return b''
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_none_from_stdout_read(self, mocker, rc_am):
"""Test that None returned from stdout.read() doesn't cause TypeError."""
rc_am._subprocess._output = {
mocker.sentinel.stdout:
self.NoneReturningBytesIO(fh=mocker.sentinel.stdout, data=b'command output'),
mocker.sentinel.stderr:
SpecialBytesIO(b'', fh=mocker.sentinel.stderr)
}
(rc, stdout, stderr) = rc_am.run_command('/bin/test')
assert rc == 0
assert stdout == 'command output'
assert stderr == ''
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_none_from_stderr_read(self, mocker, rc_am):
"""Test that None returned from stderr.read() doesn't cause TypeError."""
rc_am._subprocess._output = {
mocker.sentinel.stdout:
SpecialBytesIO(b'', fh=mocker.sentinel.stdout),
mocker.sentinel.stderr:
self.NoneReturningBytesIO(fh=mocker.sentinel.stderr, data=b'error output')
}
(rc, stdout, stderr) = rc_am.run_command('/bin/test')
assert rc == 0
assert stdout == ''
assert stderr == 'error output'
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
def test_none_from_both_pipes(self, mocker, rc_am):
"""Test that None returned from both pipes doesn't cause TypeError."""
rc_am._subprocess._output = {
mocker.sentinel.stdout:
self.NoneReturningBytesIO(fh=mocker.sentinel.stdout, data=b'stdout data'),
mocker.sentinel.stderr:
self.NoneReturningBytesIO(fh=mocker.sentinel.stderr, data=b'stderr data')
}
(rc, stdout, stderr) = rc_am.run_command('/bin/test')
assert rc == 0
assert stdout == 'stdout data'
assert stderr == 'stderr data'