Skip to content

Commit 85d6046

Browse files
committed
mitogen: Fix non-blocking IO errors in first stage of bootstrap
When /etc/sudoers has log_output (or similar) enabled the process spawned by `ctx.sudo()` via `mitogen.parent.Connection.start_child()` receives a stdin that is in non-blocking mode. The immediate symptom is that `os.openfd(0, ...).read(n)` sometimes returns `None`, causing the first stage to raise an unhandled TypeError. The fix (for now) is to use `select.select()` in a while loop to read stdin. This increases the command size slightly, but I think it's a reasonable tradeoff until/unless the cause is more fully understood. All CI tests are now run with sudoers log_output enabled, in order to catch regressions. `first_stage_test.CommandLineTest` has been amended, because it relied on implementation details of the bootstrap process that are no longer true. Before ``` SSH command size: 755 Preamble (mitogen.core + econtext) size: 18227 (17.80KiB) Original Minimized Compressed mitogen.core 152218 148.7KiB 68437 66.8KiB 45.0% 18124 17.7KiB 11.9% mitogen.parent 98853 96.5KiB 51103 49.9KiB 51.7% 12881 12.6KiB 13.0% mitogen.fork 8445 8.2KiB 4139 4.0KiB 49.0% 1652 1.6KiB 19.6% mitogen.ssh 10827 10.6KiB 6893 6.7KiB 63.7% 2099 2.0KiB 19.4% mitogen.sudo 12089 11.8KiB 5924 5.8KiB 49.0% 2249 2.2KiB 18.6% mitogen.select 12325 12.0KiB 2929 2.9KiB 23.8% 964 0.9KiB 7.8% mitogen.service 41581 40.6KiB 22398 21.9KiB 53.9% 5847 5.7KiB 14.1% mitogen.fakessh 15767 15.4KiB 8149 8.0KiB 51.7% 2676 2.6KiB 17.0% mitogen.master 55317 54.0KiB 28846 28.2KiB 52.1% 7528 7.4KiB 13.6% ``` After ``` SSH command size: 798 Preamble (mitogen.core + econtext) size: 18227 (17.80KiB) Original Minimized Compressed mitogen.core 152218 148.7KiB 68437 66.8KiB 45.0% 18124 17.7KiB 11.9% mitogen.parent 98944 96.6KiB 51180 50.0KiB 51.7% 12910 12.6KiB 13.0% mitogen.fork 8445 8.2KiB 4139 4.0KiB 49.0% 1652 1.6KiB 19.6% mitogen.ssh 10827 10.6KiB 6893 6.7KiB 63.7% 2099 2.0KiB 19.4% mitogen.sudo 12089 11.8KiB 5924 5.8KiB 49.0% 2249 2.2KiB 18.6% mitogen.select 12325 12.0KiB 2929 2.9KiB 23.8% 964 0.9KiB 7.8% mitogen.service 41581 40.6KiB 22398 21.9KiB 53.9% 5847 5.7KiB 14.1% mitogen.fakessh 15767 15.4KiB 8149 8.0KiB 51.7% 2676 2.6KiB 17.0% mitogen.master 55317 54.0KiB 28846 28.2KiB 52.1% 7528 7.4KiB 13.6% ```
1 parent c508bfb commit 85d6046

File tree

6 files changed

+39
-13
lines changed

6 files changed

+39
-13
lines changed

.ci/ci_lib.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
'MITOGEN_TEST_IMAGE_TEMPLATE',
4242
'ghcr.io/mitogen-hq/%(distro)s-test:2021',
4343
)
44+
SUDOERS_DEFAULTS_SRC = './tests/image_prep/files/sudoers_defaults'
45+
SUDOERS_DEFAULTS_DEST = '/etc/sudoers.d/mitogen_test_defaults'
4446
TESTS_SSH_PRIVATE_KEY_FILE = os.path.join(GIT_ROOT, 'tests/data/docker/mitogen__has_sudo_pubkey.key')
4547

4648

.ci/mitogen_tests.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Run the Mitogen tests.
33

44
import os
5+
import subprocess
56

67
import ci_lib
78

@@ -13,6 +14,14 @@
1314
if not ci_lib.have_docker():
1415
os.environ['SKIP_DOCKER_TESTS'] = '1'
1516

17+
subprocess.check_call(
18+
"umask 0022; sudo cp '%s' '%s'"
19+
% (ci_lib.SUDOERS_DEFAULTS_SRC, ci_lib.SUDOERS_DEFAULTS_DEST),
20+
shell=True,
21+
)
22+
subprocess.check_call(['sudo', 'visudo', '-cf', ci_lib.SUDOERS_DEFAULTS_DEST])
23+
subprocess.check_call(['sudo', '-l'])
24+
1625
interesting = ci_lib.get_interesting_procs()
1726
ci_lib.run('./run_tests -v')
1827
ci_lib.check_stray_processes(interesting)

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ To avail of fixes in an unreleased version, please download a ZIP file
2121
In progress (unreleased)
2222
------------------------
2323

24+
* :gh:issue:`1306` :mod:`ansible_mitogen`: Fix non-blocking IO errors in
25+
first stage of bootstrap
2426
* :gh:issue:`1306` CI: Report sudo version on Ansible targets
2527
* :gh:issue:`1306` CI: Move sudo test users defaults into ``/etc/sudoers.d``
2628
* :gh:issue:`1306` preamble_size: Fix variability of measured command size

mitogen/parent.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,9 +1440,9 @@ def _first_stage():
14401440
os.environ['ARGV0']=sys.executable
14411441
os.execl(sys.executable,sys.executable+'(mitogen:CONTEXT_NAME)')
14421442
os.write(1,'MITO000\n'.encode())
1443-
fp=os.fdopen(0,'rb')
1444-
C=zlib.decompress(fp.read(PREAMBLE_COMPRESSED_LEN))
1445-
fp.close()
1443+
C=''.encode()
1444+
while PREAMBLE_COMPRESSED_LEN-len(C)and select.select([0],[],[]):C+=os.read(0,PREAMBLE_COMPRESSED_LEN-len(C))
1445+
C=zlib.decompress(C)
14461446
fp=os.fdopen(W,'wb',0)
14471447
fp.write(C)
14481448
fp.close()
@@ -1478,11 +1478,12 @@ def get_boot_command(self):
14781478

14791479
# Just enough to decode, decompress, and exec the first stage.
14801480
# Priorities: wider compatibility, faster startup, shorter length.
1481-
# `import os` here, instead of stage 1, to save a few bytes.
14821481
# `sys.path=...` for https://github.com/python/cpython/issues/115911.
1482+
# `import os,select` here (not stage 1) to save a few bytes overall.
14831483
return self.get_python_argv() + [
14841484
'-c',
1485-
'import sys;sys.path=[p for p in sys.path if p];import binascii,os,zlib;'
1485+
'import sys;sys.path=[p for p in sys.path if p];'
1486+
'import binascii,os,select,zlib;'
14861487
'exec(zlib.decompress(binascii.a2b_base64("%s")))' % (encoded.decode(),),
14871488
]
14881489

tests/first_stage_test.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import subprocess
22

3+
import mitogen.core
34
import mitogen.parent
45
from mitogen.core import b
56

@@ -21,14 +22,18 @@ def test_valid_syntax(self):
2122
conn.context = mitogen.core.Context(None, 123)
2223
args = conn.get_boot_command()
2324

24-
# Executing the boot command will print "EC0" and expect to read from
25-
# stdin, which will fail because it's pointing at /dev/null, causing
26-
# the forked child to crash with an EOFError and disconnect its write
27-
# pipe. The forked and freshly execed parent will get a 0-byte read
28-
# from the pipe, which is a valid script, and therefore exit indicating
29-
# success.
25+
# The boot command should write an ECO marker to stdout, read the
26+
# preamble from stdin, then execute it.
3027

31-
fp = open("/dev/null", "r")
28+
# This test attaches /dev/zero to stdin to create a specific failure
29+
# 1. Fork child reads PREAMBLE_COMPRESSED_LEN bytes of junk (all `\0`)
30+
# 2. Fork child crashes (trying to decompress the junk data)
31+
# 3. Fork child's file descriptors (write pipes) are closed by the OS
32+
# 4. Fork parent does `dup(<read pipe>, <stdin>)` and `exec(<python>)`
33+
# 5. Python reads `b''` (i.e. EOF) from stdin (a closed pipe)
34+
# 6. Python runs `''` (a valid script) and exits with success
35+
36+
fp = open("/dev/zero", "r")
3237
try:
3338
proc = subprocess.Popen(args,
3439
stdin=fp,
@@ -39,6 +44,9 @@ def test_valid_syntax(self):
3944
self.assertEqual(0, proc.returncode)
4045
self.assertEqual(stdout,
4146
mitogen.parent.BootstrapProtocol.EC0_MARKER+b('\n'))
42-
self.assertIn(b("Error -5 while decompressing data"), stderr)
47+
self.assertIn(
48+
b("Error -3 while decompressing data"), # Unknown compression method
49+
stderr,
50+
)
4351
finally:
4452
fp.close()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Testing non-blocking stdio during bootstrap
2+
# https://github.com/mitogen-hq/mitogen/issues/1306
3+
Defaults log_output
4+
15
Defaults>mitogen__pw_required targetpw
26
Defaults>mitogen__require_tty requiretty
37
Defaults>mitogen__require_tty_pw_required requiretty,targetpw

0 commit comments

Comments
 (0)