Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pytest_assertrepr_compare unnecessarily calls os.environ.update every time - increased segfault risk due to getenv not thread safe #955

Open
sparrowt opened this issue Feb 20, 2025 · 2 comments
Labels
bug Something isn't working Impact Partially Mitigated

Comments

@sparrowt
Copy link
Contributor

sparrowt commented Feb 20, 2025

Describe the bug

The short version: having syrupy installed appears to greatly increase the chances of a segmentation fault during multi-threaded browser testing using playwright.

The long version:

To reproduce

Because it is a threading race there is no 100% repro, but in my case it happened approx 1 in 10 times during execution of a test suite of 235 playwright browser integration tests.

This is not an open source codebase so I'm unfortunately unable to provide an example project.

However patching syrupy to not call env_context and then running this same playwright test suite repeatedly (200 times!) overnight it did not segfault at all - whereas previously it would reliably reproduce within 5-15 runs.

Also this was first seen in our CI automation within 1 day after the PR which added syrupy was merged so there is a strong correlation.

Expected behavior

Simply installing syrupy should not cause intermittent segfaults in unrelated code.

Environment

  • OS: Amazon Linux 2023 Docker container
  • Syrupy Version: 4.7.1
  • Python Version: 3.12.9

Further details

In order to help with googling for this issue, instead of screenshots here are textual stack traces (both python faulthandler stacks at exit and also gdb backtrace from the segfault core dump)

GDB backtrace from the segfault, showing the getenv call which died:

(gdb) bt
#0  0x00007febf2b1e57c in __pthread_kill_implementation () from /lib64/libc.so.6
#1  0x00007febf2ad1d36 in raise () from /lib64/libc.so.6
#2  <signal handler called>
#3  0x00007febf2ad3c1d in getenv () from /lib64/libc.so.6
#4  0x00007febed87fbb4 in k5_init_trace () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libkrb5-fcafa220.so.3.3
#5  0x00007febed84dd68 in krb5_init_context_profile () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libkrb5-fcafa220.so.3.3
#6  0x00007febedb80d92 in krb5_gss_acquire_cred_from () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libgssapi_krb5-497db0c6.so.2.2
#7  0x00007febedb6d160 in gss_add_cred_from () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libgssapi_krb5-497db0c6.so.2.2
#8  0x00007febedb6d7a9 in gss_acquire_cred_from () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libgssapi_krb5-497db0c6.so.2.2
#9  0x00007febedb6d9c4 in gss_acquire_cred () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libgssapi_krb5-497db0c6.so.2.2
#10 0x00007febee177320 in ?? () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libpq-e8a033dd.so.5.16
#11 0x00007febee161df4 in PQconnectPoll () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libpq-e8a033dd.so.5.16
#12 0x00007febee162734 in ?? () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libpq-e8a033dd.so.5.16
#13 0x00007febee165b68 in PQconnectdb () from /path/to/python/lib/python3.12/site-packages/psycopg2/../psycopg2_binary.libs/libpq-e8a033dd.so.5.16
#14 0x00007febf1957558 in ?? () from /path/to/python/lib/python3.12/site-packages/psycopg2/_psycopg.cpython-312-x86_64-linux-gnu.so
#15 0x00007febf19587c0 in ?? () from /path/to/python/lib/python3.12/site-packages/psycopg2/_psycopg.cpython-312-x86_64-linux-gnu.so
#16 0x00000000005452fc in type_call (type=<optimized out>, type@entry=0x7febf1989420, args=args@entry=0x7febd7a927a0, kwds=kwds@entry=0x0) at Objects/typeobject.c:1679
#17 0x000000000051764a in _PyObject_MakeTpCall (tstate=0x7febe0016bd0, callable=0x7febf1989420, args=0x7febd72fbb50, nargs=1, keywords=0x0) at Objects/call.c:240
#18 0x0000000000517458 in _PyObject_VectorcallTstate (kwnames=0x0, nargsf=<optimized out>, args=0x7febd72fbb50, callable=0x7febf1989420, tstate=0x7febe0016bd0) at ./Include/internal/pycore_call.h:90
#19 _PyObject_VectorcallTstate (kwnames=0x0, nargsf=<optimized out>, args=0x7febd72fbb50, callable=0x7febf1989420, tstate=0x7febe0016bd0) at ./Include/internal/pycore_call.h:77
#20 _PyObject_CallFunctionVa (tstate=0x7febe0016bd0, callable=0x7febf1989420, format=<optimized out>, va=<optimized out>, is_size_t=<optimized out>) at Objects/call.c:562
#21 0x00000000005b0f04 in _PyObject_CallFunction_SizeT (callable=<optimized out>, format=<optimized out>) at Objects/call.c:616
#22 0x00007febf1967069 in ?? () from /path/to/python/lib/python3.12/site-packages/psycopg2/_psycopg.cpython-312-x86_64-linux-gnu.so
#23 0x000000000053a05b in cfunction_call (func=0x7febee268a40, args=<optimized out>, kwargs=<optimized out>) at Objects/methodobject.c:537
#24 0x0000000000518bcb in PyObject_Call ()
#25 0x000000000056974a in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=0x7febef78cba0, throwflag=<optimized out>) at Python/bytecodes.c:3263
#26 0x0000000000519356 in _PyObject_VectorcallTstate (kwnames=0x0, nargsf=1, args=0x7febd72fbef8, callable=0x7febef1dc5e0, tstate=0x7febe0016bd0) at ./Include/internal/pycore_call.h:92
#27 method_vectorcall (method=method@entry=0x7febd75e6880, args=args@entry=0x0, nargsf=nargsf@entry=0, kwnames=kwnames@entry=0x0) at Objects/classobject.c:69
#28 0x00000000005699ee in _PyObject_VectorcallTstate (args=0x0, nargsf=0, kwnames=0x0, callable=0x7febd75e6880, tstate=0x7febe0016bd0) at ./Include/internal/pycore_call.h:92
#29 _PyObject_CallNoArgs (func=0x7febd75e6880) at ./Include/internal/pycore_call.h:108
#30 _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=0x7febef78c6d0, throwflag=<optimized out>) at Python/bytecodes.c:2515
#31 0x0000000000518406 in _PyFunction_Vectorcall (kwnames=0x0, nargsf=<optimized out>, stack=0x7febd72fc130, func=0x7febe6b0e340) at Objects/call.c:419
#32 _PyObject_FastCallDictTstate (kwargs=0x7febe00012e0, nargsf=<optimized out>, args=0x7febd72fc130, callable=0x7febe6b0e340, tstate=0x7febe0016bd0) at Objects/call.c:133
#33 _PyObject_Call_Prepend (tstate=tstate@entry=0x7febe0016bd0, callable=callable@entry=0x7febe6b0e340, obj=obj@entry=0x7febe6b1f470, args=args@entry=0x7febd7a90910, kwargs=kwargs@entry=0x0)
    at Objects/call.c:508
#34 0x00000000005cfbab in slot_tp_call (self=self@entry=0x7febe6b1f470, args=args@entry=0x7febd7a90910, kwds=kwds@entry=0x0) at Objects/typeobject.c:8791
#35 0x000000000051764a in _PyObject_MakeTpCall (tstate=0x7febe0016bd0, callable=0x7febe6b1f470, args=0x7febef78c4b0, nargs=1, keywords=0x0) at Objects/call.c:240
#36 0x00000000005655fa in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=0x7febef78c438, throwflag=<optimized out>) at Python/bytecodes.c:2715
#37 0x0000000000518406 in _PyFunction_Vectorcall (kwnames=0x0, nargsf=<optimized out>, stack=0x7febd72fc400, func=0x7febe9f65440) at Objects/call.c:419
...
(a bunch more _PyXYZ stack frames omitted)
...
#116 _PyObject_FastCallDictTstate (kwargs=<optimized out>, nargsf=<optimized out>, args=0x7febd72fe890, callable=0x7febee7409a0, tstate=0x7febe0016bd0) at Objects/call.c:133
#117 _PyObject_Call_Prepend (tstate=tstate@entry=0x7febe0016bd0, callable=callable@entry=0x7febee7409a0, obj=obj@entry=0x7febd795d130, args=args@entry=0x7febd78af8c0, kwargs=<optimized out>)
    at Objects/call.c:508
#118 0x000000000054694c in slot_tp_init (self=0x7febd795d130, args=0x7febd78af8c0, kwds=<optimized out>) at Objects/typeobject.c:9035
#119 0x00000000005452fc in type_call (type=<optimized out>, type@entry=0x3745700, args=args@entry=0x7febd78af8c0, kwds=kwds@entry=0x0) at Objects/typeobject.c:1679
#120 0x000000000051764a in _PyObject_MakeTpCall (tstate=0x7febe0016bd0, callable=0x3745700, args=0x7febef78b318, nargs=3, keywords=0x0) at Objects/call.c:240
#121 0x00000000005655fa in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=0x7febef78b2a8, throwflag=<optimized out>) at Python/bytecodes.c:2715
#122 0x0000000000519297 in _PyObject_VectorcallTstate (kwnames=<optimized out>, nargsf=3, args=0x7febd72feb90, callable=0x7febea2b56c0, tstate=0x7febe0016bd0) at ./Include/internal/pycore_call.h:92
#123 method_vectorcall (method=<optimized out>, args=<optimized out>, nargsf=<optimized out>, kwnames=<optimized out>) at Objects/classobject.c:91
#124 0x000000000056974a in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=0x7febef78b110, throwflag=<optimized out>) at Python/bytecodes.c:3263
#125 0x0000000000519356 in _PyObject_VectorcallTstate (kwnames=0x0, nargsf=1, args=0x7febd72feda8, callable=0x7febf254aa20, tstate=0x7febe0016bd0) at ./Include/internal/pycore_call.h:92
#126 method_vectorcall (method=<optimized out>, args=0x9307c8 <_PyRuntime+75624>, nargsf=<optimized out>, kwnames=<optimized out>) at Objects/classobject.c:69
#127 0x0000000000635885 in thread_run (boot_raw=0x7febe0001c70) at ./Modules/_threadmodule.c:1114
#128 0x0000000000615ce4 in pythread_wrapper (arg=<optimized out>) at Python/thread_pthread.h:237
#129 0x00007febf2b1c832 in start_thread () from /lib64/libc.so.6
#130 0x00007febf2abc314 in clone () from /lib64/libc.so.6
(gdb)

Python stack traces at the time of the segfault:

  • the first thread is the one which ended up calling getenv
  • the last thread is in our test code handle_console_message which at line 154 is doing assert msg.type == "verbose" or page.title() == "Expected title" which fits with syrupy having recently executed pytest_assertrepr_compare for the first equality comparison
Fatal Python error: Segmentation fault

Current thread 0x00007febd72ff640 (most recent call first):
  File "/path/to/python/lib/python3.12/site-packages/psycopg2/__init__.py", line 122 in connect
  File "/path/to/python/lib/python3.12/site-packages/django/db/backends/postgresql/base.py", line 275 in get_new_connection
  File "/path/to/python/lib/python3.12/site-packages/django/utils/asyncio.py", line 26 in inner
  File "/path/to/python/lib/python3.12/site-packages/django/db/backends/base/base.py", line 270 in connect
  File "/path/to/python/lib/python3.12/site-packages/django/utils/asyncio.py", line 26 in inner
  File "/path/to/python/lib/python3.12/site-packages/django/db/backends/base/base.py", line 289 in ensure_connection
  File "/path/to/python/lib/python3.12/site-packages/django/utils/asyncio.py", line 26 in inner
  File "/path/to/python/lib/python3.12/site-packages/django/db/backends/base/base.py", line 464 in get_autocommit
  File "/path/to/python/lib/python3.12/site-packages/django/db/transaction.py", line 198 in __enter__
  File "/python/lib/python3.12/contextlib.py", line 80 in inner
  File "/path/to/python/lib/python3.12/site-packages/django/core/handlers/base.py", line 197 in _get_response
  ...
  ... (some middleware frames omitted for brevity)
  ...
  File "/path/to/python/lib/python3.12/site-packages/django/core/handlers/exception.py", line 55 in inner
  File "/path/to/python/lib/python3.12/site-packages/django/utils/deprecation.py", line 134 in __call__
  File "/path/to/python/lib/python3.12/site-packages/django/core/handlers/exception.py", line 55 in inner
  File "/path/to/python/lib/python3.12/site-packages/django/core/handlers/base.py", line 140 in get_response
  File "/path/to/python/lib/python3.12/site-packages/django/test/testcases.py", line 1709 in get_response
  File "/path/to/python/lib/python3.12/site-packages/django/core/handlers/wsgi.py", line 124 in __call__
  File "/path/to/python/lib/python3.12/site-packages/django/test/testcases.py", line 1723 in __call__
  File "/path/to/python/lib/python3.12/site-packages/django/contrib/staticfiles/handlers.py", line 80 in __call__
  File "/python/lib/python3.12/wsgiref/handlers.py", line 137 in run
  File "/path/to/python/lib/python3.12/site-packages/django/core/servers/basehttp.py", line 252 in handle_one_request
  File "/path/to/python/lib/python3.12/site-packages/django/core/servers/basehttp.py", line 229 in handle
  File "/python/lib/python3.12/socketserver.py", line 766 in __init__
  File "/python/lib/python3.12/socketserver.py", line 362 in finish_request
  File "/python/lib/python3.12/socketserver.py", line 697 in process_request_thread
  File "/path/to/python/lib/python3.12/site-packages/django/core/servers/basehttp.py", line 103 in process_request_thread
  File "/python/lib/python3.12/threading.py", line 1012 in run
  File "/python/lib/python3.12/threading.py", line 1075 in _bootstrap_inner
  File "/python/lib/python3.12/threading.py", line 1032 in _bootstrap

Thread 0x00007febbe0fc640 (most recent call first):
  File "/python/lib/python3.12/socket.py", line 720 in readinto
  File "/path/to/python/lib/python3.12/site-packages/django/core/servers/basehttp.py", line 237 in handle_one_request
  File "/path/to/python/lib/python3.12/site-packages/django/core/servers/basehttp.py", line 229 in handle
  File "/python/lib/python3.12/socketserver.py", line 766 in __init__
  File "/python/lib/python3.12/socketserver.py", line 362 in finish_request
  File "/python/lib/python3.12/socketserver.py", line 697 in process_request_thread
  File "/path/to/python/lib/python3.12/site-packages/django/core/servers/basehttp.py", line 103 in process_request_thread
  File "/python/lib/python3.12/threading.py", line 1012 in run
  File "/python/lib/python3.12/threading.py", line 1075 in _bootstrap_inner
  File "/python/lib/python3.12/threading.py", line 1032 in _bootstrap

Thread 0x00007febe53d7640 (most recent call first):
  File "/python/lib/python3.12/asyncio/unix_events.py", line 1408 in _do_waitpid
  File "/python/lib/python3.12/threading.py", line 1012 in run
  File "/python/lib/python3.12/threading.py", line 1075 in _bootstrap_inner
  File "/python/lib/python3.12/threading.py", line 1032 in _bootstrap

Thread 0x00007febe5dd8640 (most recent call first):
  File "/python/lib/python3.12/selectors.py", line 415 in select
  File "/python/lib/python3.12/socketserver.py", line 235 in serve_forever
  File "/python/lib/python3.12/threading.py", line 1012 in run
  File "/python/lib/python3.12/threading.py", line 1075 in _bootstrap_inner
  File "/python/lib/python3.12/threading.py", line 1032 in _bootstrap

Thread 0x00007febe6ad9640 (most recent call first):
  File "/python/lib/python3.12/selectors.py", line 415 in select
  File "/python/lib/python3.12/socketserver.py", line 235 in serve_forever
  File "/path/to/python/lib/python3.12/site-packages/django/test/testcases.py", line 1787 in run
  File "/python/lib/python3.12/threading.py", line 1075 in _bootstrap_inner
  File "/python/lib/python3.12/threading.py", line 1032 in _bootstrap

Thread 0x00007febf2a7a740 (most recent call first):
  File "/python/lib/python3.12/inspect.py", line 1078 in getsourcefile
  File "/python/lib/python3.12/inspect.py", line 1718 in getframeinfo
  File "/python/lib/python3.12/inspect.py", line 1756 in getouterframes
  File "/python/lib/python3.12/inspect.py", line 1781 in stack
  File "/path/to/python/lib/python3.12/site-packages/playwright/_impl/_sync_base.py", line 108 in _sync
  File "/path/to/python/lib/python3.12/site-packages/playwright/sync_api/_generated.py", line 9572 in title
  File "/path/to/our/tests/conftest.py", line 154 in handle_console_message
  File "/path/to/python/lib/python3.12/site-packages/playwright/_impl/_impl_to_api_mapping.py", line 123 in wrapper_func
  File "/path/to/python/lib/python3.12/site-packages/pyee/asyncio.py", line 51 in _emit_run
  File "/path/to/python/lib/python3.12/site-packages/pyee/base.py", line 184 in _call_handlers
  File "/path/to/python/lib/python3.12/site-packages/pyee/base.py", line 208 in emit
  File "/path/to/python/lib/python3.12/site-packages/playwright/_impl/_browser_context.py", line 630 in _on_console_message
  File "/path/to/python/lib/python3.12/site-packages/playwright/_impl/_browser_context.py", line 147 in <lambda>
  File "/path/to/python/lib/python3.12/site-packages/playwright/_impl/_connection.py", line 430 in _listener_with_error_handler_attached

Extension modules: greenlet._greenlet, yaml._yaml, simplejson._speedups, msgpack._cmsgpack, bitarray._bitarray, bitarray._util, lxml._elementpath, lxml.etree, psycopg2._psycopg, _cffi_backend, markupsafe._speedups (total: 11)

Stack trace showing syrupy updating the environment (as described in the bullet points at the top)

  /path/to/python/lib/site-packages/playwright/_impl/_connection.py(430)_listener_with_error_handler_attached()
-> potential_future = listener(params)
  /path/to/python/lib/site-packages/playwright/_impl/_browser_context.py(147)<lambda>()
-> lambda event: self._on_console_message(event),
  /path/to/python/lib/site-packages/playwright/_impl/_browser_context.py(630)_on_console_message()
-> page.emit(Page.Events.Console, message)
  /path/to/python/lib/site-packages/pyee/base.py(208)emit()
-> handled = self._call_handlers(event, args, kwargs)
  /path/to/python/lib/site-packages/pyee/base.py(184)_call_handlers()
-> self._emit_run(f, args, kwargs)
  /path/to/python/lib/site-packages/pyee/asyncio.py(51)_emit_run()
-> coro: Any = f(*args, **kwargs)
  /path/to/python/lib/site-packages/playwright/_impl/_impl_to_api_mapping.py(123)wrapper_func()
-> return handler(
  /path/to/our/tests/conftest.py(154)handle_console_message()
-> assert msg.type == "verbose" or page.title() == "Expected title"
  /path/to/python/lib/site-packages/_pytest/assertion/rewrite.py(499)_call_reprcompare()
-> custom = util._reprcompare(ops[i], each_obj[i], each_obj[i + 1])
  /path/to/python/lib/site-packages/_pytest/assertion/__init__.py(141)callbinrepr()
-> hook_result = ihook.pytest_assertrepr_compare(
  /path/to/python/lib/site-packages/pluggy/_hooks.py(493)__call__()
-> return self._hookexec(self.name, self._hookimpls, kwargs, firstresult)
  /path/to/python/lib/site-packages/pluggy/_manager.py(115)_hookexec()
-> return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  /path/to/python/lib/site-packages/pluggy/_callers.py(77)_multicall()
-> res = hook_impl.function(*args)
  /path/to/python/lib/site-packages/syrupy/__init__.py(114)pytest_assertrepr_compare()
-> with __terminal_color(config):
  /path/to/python/lib/contextlib.py(144)__exit__()
-> next(self.gen)
  /path/to/python/lib/site-packages/syrupy/utils.py(89)env_context()
-> os.environ.update(prev_env)
  <frozen _collections_abc>(982)update()
sparrowt added a commit to sparrowt/syrupy that referenced this issue Feb 20, 2025
See syrupy-project#955 for a
detailed explanation of why this can cause thread safety issues
resulting in a segfault when another thread calls `getenv`
sparrowt added a commit to sparrowt/syrupy that referenced this issue Feb 20, 2025
If neither operand is a `SnapshotAssertion` then the rest of the
code is not going to do anything, so bail early rather than
unnecessarily setting up `__terminal_color` etc. which can cause
issues c.f. syrupy-project#955
@sparrowt
Copy link
Contributor Author

I've opened #956 to significantly reduce the probability of this occurring, though as noted there the possibility is not entirely gone.

Perhaps a more watertight way to fix this is to avoid modifying os.environ at all and use a different mechanism of signalling whether terminal colouring is enabled or not, e.g. using contextvars?

@noahnu noahnu added bug Something isn't working Impact Partially Mitigated labels Feb 20, 2025
noahnu added a commit that referenced this issue Feb 20, 2025
* fix: __terminal_color shouldn't modify os.environ unless it has to

See #955 for a
detailed explanation of why this can cause thread safety issues
resulting in a segfault when another thread calls `getenv`

* fix: pytest_assertrepr_compare should be a no-op for other types

If neither operand is a `SnapshotAssertion` then the rest of the
code is not going to do anything, so bail early rather than
unnecessarily setting up `__terminal_color` etc. which can cause
issues c.f. #955

* chore: run linter

---------

Co-authored-by: Noah Ulster <noah.u@roserocket.com>
@noahnu
Copy link
Collaborator

noahnu commented Feb 20, 2025

https://github.com/syrupy-project/syrupy/releases/tag/v4.8.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working Impact Partially Mitigated
Projects
None yet
Development

No branches or pull requests

2 participants