Skip to content

Commit 266c3a9

Browse files
authored
Fix SystemError buffer overflow on Python 3.14+ and add Python 3.14 support (#351)
* Fix SystemError buffer overflow on Python 3.14+ and add Python 3.14 support This commit addresses issue Backblaze#1119 where B2 CLI crashes with SystemError on Python 3.14+ due to a buffer overflow bug in the rst2ansi dependency. Changes: - Add try-except handler in arg_parser.py to catch SystemError from rst2ansi - Fall back to plain text formatting when rst2ansi fails - Add integration test to verify the fix works with real PTY on Python 3.14 - Add dedicated nox session (integration_pty) for PTY tests without xdist - Add Python 3.14 to noxfile.py, CI matrix, and pyproject.toml The root cause is in rst2ansi's get_terminal_size() which passes a 4-byte buffer to TIOCGWINSZ ioctl instead of the required 8 bytes. Python 3.14 enforces stricter buffer validation, triggering the error. Fixes Backblaze#1119 * Run PTY test directly in CI without xdist, remove dedicated nox session The PTY test doesn't work properly with pytest-xdist parallelization. Instead of a dedicated nox session, run it directly in CI on Python 3.14 without xdist to properly test the buffer overflow fix. * Fix CI condition syntax for PTY test The condition needs to be wrapped in ${{ }} for proper evaluation. * Remove separate PTY CI step - regular integration tests catch the bug The test_help_with_tty test works correctly with pytest-xdist in CI, so we don't need a separate step. Updated docstring to note that local execution may not trigger the bug due to environment differences. * Use skipif decorator directly, consistent with repo pattern * Simplify test docstring * Extract patched_spawn to test/helpers.py for reuse Move pexpect wrapper with improved error messages from test_autocomplete to shared helpers module, and use it in test_help PTY test. * Filter resource_tracker warnings in integration tests Python 3.14+ emits multiprocessing resource_tracker warnings about leaked semaphores during test subprocess cleanup. These warnings appear in stderr and cause test assertions to fail. Filter them out the same way we already filter DeprecationWarnings. Fixes integration test failures on Python 3.14.
1 parent 8068fa0 commit 266c3a9

10 files changed

Lines changed: 117 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ jobs:
8686
fail-fast: false
8787
matrix:
8888
os: ["ubuntu-latest", "ubuntu-24.04-arm", "macos-latest", "windows-latest"]
89-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10"]
89+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10"]
9090
exclude:
9191
- os: "macos-latest"
9292
python-version: "pypy3.10"
@@ -118,7 +118,7 @@ jobs:
118118
run: nox -vs integration -p ${{ matrix.python-version }} -- -m "not require_secrets"
119119
- name: Run integration tests (with secrets)
120120
# Limit CI workload by running integration tests with secrets only on edge Python versions.
121-
if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && contains(fromJSON('["3.8", "pypy3.10", "3.13"]'), matrix.python-version) }}
121+
if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && contains(fromJSON('["3.8", "pypy3.10", "3.14"]'), matrix.python-version) }}
122122
run: nox -vs integration -p ${{ matrix.python-version }} -- -m "require_secrets" --cleanup
123123
test-docker:
124124
timeout-minutes: 90

b2/_internal/arg_parser.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,14 @@ def _encode_description(self, value: str):
130130
return textwrap.dedent(value)
131131
else:
132132
encoding = self._get_encoding()
133-
return rst2ansi(value.encode(encoding), output_encoding=encoding)
133+
try:
134+
return rst2ansi(value.encode(encoding), output_encoding=encoding)
135+
except SystemError:
136+
# FALLBACK(PARSER): rst2ansi can raise SystemError on Python 3.14+ due to
137+
# buffer overflow bug in get_terminal_size ioctl call.
138+
# See: https://github.com/Backblaze/B2_Command_Line_Tool/issues/1119
139+
# TODO-REMOVE-BY: When rst2ansi is updated or replaced
140+
return textwrap.dedent(value)
134141

135142
def _make_short_description(self, usage: str, raw_description: str) -> str:
136143
if usage:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added Python 3.14 support to CI/CD pipeline and test matrix.

changelog.d/1119.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed SystemError buffer overflow crash on Python 3.14+ caused by rst2ansi's terminal size detection bug. The CLI now gracefully handles this error and continues to function normally.

noxfile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
'3.11',
4343
'3.12',
4444
'3.13',
45+
'3.14',
4546
]
4647
if NOX_PYTHONS is None
4748
else NOX_PYTHONS.split(',')

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ classifiers = [
2121
"Programming Language :: Python :: 3.11",
2222
"Programming Language :: Python :: 3.12",
2323
"Programming Language :: Python :: 3.13",
24+
"Programming Language :: Python :: 3.14",
2425
]
2526
dependencies = [
2627
"argcomplete>=3.5.2,<4",

test/helpers.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
#
99
######################################################################
1010
import platform
11+
import sys
1112

13+
import pexpect
1214
import pytest
1315

1416
_MISSING = object()
@@ -21,6 +23,40 @@ def skip_on_windows(*args, reason='Not supported on Windows', **kwargs):
2123
)(*args, **kwargs)
2224

2325

26+
def patched_spawn(*args, **kwargs):
27+
"""
28+
Wrapper around pexpect.spawn with improved error messages.
29+
30+
pexpect's errors are confusing to interpret when things go wrong,
31+
because it doesn't output the actual stdout by default. This wrapper
32+
addresses that inconvenience.
33+
"""
34+
instance = pexpect.spawn(*args, **kwargs)
35+
36+
def _patch_expect(func):
37+
def _wrapper(pattern_list, **kwargs):
38+
try:
39+
return func(pattern_list, **kwargs)
40+
except pexpect.exceptions.TIMEOUT as exc:
41+
raise pexpect.exceptions.TIMEOUT(
42+
f'Timeout reached waiting for `{pattern_list}`'
43+
) from exc
44+
except pexpect.exceptions.EOF as exc:
45+
raise pexpect.exceptions.EOF(f'Received EOF waiting for `{pattern_list}`') from exc
46+
except Exception as exc:
47+
raise RuntimeError(f'Unexpected error waiting for `{pattern_list}`') from exc
48+
49+
return _wrapper
50+
51+
instance.expect = _patch_expect(instance.expect)
52+
instance.expect_exact = _patch_expect(instance.expect_exact)
53+
54+
# capture child shell's output for debugging
55+
instance.logfile = sys.stdout.buffer
56+
57+
return instance
58+
59+
2460
def b2_uri_args_v3(bucket_name, path=_MISSING):
2561
if path is _MISSING:
2662
return [bucket_name]

test/integration/helpers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,15 @@ def print_json_indented(value):
368368

369369

370370
def remove_warnings(text):
371-
return linesep.join(line for line in text.split(linesep) if 'DeprecationWarning' not in line)
371+
"""Filter out Python warnings from command output."""
372+
return linesep.join(
373+
line
374+
for line in text.split(linesep)
375+
if 'DeprecationWarning' not in line
376+
and 'resource_tracker' not in line # Python 3.14+ multiprocessing warnings
377+
and 'UserWarning' not in line # Python 3.14+ shows more warning details
378+
and 'warnings.warn(' not in line # Python 3.14+ shows source line in warnings
379+
)
372380

373381

374382
class StringReader:

test/integration/test_autocomplete.py

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pexpect
1414
import pytest
1515

16-
from test.helpers import skip_on_windows
16+
from test.helpers import patched_spawn, skip_on_windows
1717

1818
TIMEOUT = 120 # CI can be slow at times when parallelization is extreme
1919

@@ -27,41 +27,6 @@
2727
"""
2828

2929

30-
def patched_spawn(*args, **kwargs):
31-
"""
32-
Patch pexpect.spawn to improve error messages
33-
"""
34-
35-
instance = pexpect.spawn(*args, **kwargs)
36-
37-
def _patch_expect(func):
38-
def _wrapper(pattern_list, **kwargs):
39-
try:
40-
return func(pattern_list, **kwargs)
41-
except pexpect.exceptions.TIMEOUT as exc:
42-
raise pexpect.exceptions.TIMEOUT(
43-
f'Timeout reached waiting for `{pattern_list}` to be autocompleted'
44-
) from exc
45-
except pexpect.exceptions.EOF as exc:
46-
raise pexpect.exceptions.EOF(
47-
f'Received EOF waiting for `{pattern_list}` to be autocompleted'
48-
) from exc
49-
except Exception as exc:
50-
raise RuntimeError(
51-
f'Unexpected error waiting for `{pattern_list}` to be autocompleted'
52-
) from exc
53-
54-
return _wrapper
55-
56-
instance.expect = _patch_expect(instance.expect)
57-
instance.expect_exact = _patch_expect(instance.expect_exact)
58-
59-
# capture child shell's output for debugging
60-
instance.logfile = sys.stdout.buffer
61-
62-
return instance
63-
64-
6530
@pytest.fixture(scope='session')
6631
def bashrc(homedir):
6732
bashrc_path = homedir / '.bashrc'

test/integration/test_help.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
77
# License https://www.backblaze.com/using_b2_code.html
88
#
99
######################################################################
10+
import os
1011
import platform
1112
import re
1213
import subprocess
1314

15+
import pexpect
16+
import pytest
17+
18+
from test.helpers import patched_spawn
19+
1420

1521
def test_help(cli_version):
1622
p = subprocess.run(
@@ -26,3 +32,54 @@ def test_help(cli_version):
2632
expected_name += '.exe'
2733
assert re.match(r'^_?b2(v\d+)?(\.exe)?$', expected_name) # test sanity check
2834
assert f'{expected_name} <command> --help' in p.stdout
35+
36+
37+
@pytest.mark.skipif(
38+
platform.system() == 'Windows',
39+
reason='PTY tests require Unix-like system',
40+
)
41+
def test_help_with_tty(cli_version):
42+
"""
43+
Test that B2 CLI --help works correctly with a real PTY.
44+
45+
Verifies fix for rst2ansi buffer overflow on Python 3.14+.
46+
See: https://github.com/Backblaze/B2_Command_Line_Tool/issues/1119
47+
48+
NOTE: Works in CI with pytest-xdist, but may not trigger the bug locally.
49+
"""
50+
# Set up environment - remove LINES/COLUMNS to ensure ioctl is called
51+
env = os.environ.copy()
52+
env.pop('LINES', None)
53+
env.pop('COLUMNS', None)
54+
55+
# Spawn b2 --help with pexpect to create a real PTY
56+
# This is where the bug would trigger on Python 3.14 without our fix
57+
child = patched_spawn(
58+
cli_version,
59+
['--help'],
60+
env=env,
61+
timeout=10,
62+
)
63+
64+
# Wait for process to complete
65+
child.expect(pexpect.EOF)
66+
67+
# Get the output
68+
output = child.before.decode('utf-8', errors='replace')
69+
70+
# Check exit status
71+
child.close()
72+
exit_code = child.exitstatus
73+
74+
# Verify the command succeeded and produced help output
75+
assert exit_code == 0, (
76+
f'b2 --help failed with exit code {exit_code}.\n'
77+
f'This may indicate the buffer overflow bug is not properly handled.\n'
78+
f'Output: {output}\n'
79+
f'See: https://github.com/Backblaze/B2_Command_Line_Tool/issues/1119'
80+
)
81+
82+
# Verify help output contains expected content
83+
assert (
84+
'b2 <command>' in output or cli_version in output
85+
), f'Help output does not contain expected content.\nOutput: {output}'

0 commit comments

Comments
 (0)