Skip to content

Commit 5ddc1e7

Browse files
Handle failure during thread creation (#2471)
In Python 3.12 when you try to start a thread during shutdown a RuntimeError is raised. Handle this case with grace. --------- Co-authored-by: Ivana Kellyerova <[email protected]>
1 parent bffaeda commit 5ddc1e7

File tree

8 files changed

+174
-5
lines changed

8 files changed

+174
-5
lines changed

sentry_sdk/metrics.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ def _ensure_thread(self):
348348
try:
349349
self._flusher.start()
350350
except RuntimeError:
351-
# Unfortunately at this point the interpreter is in a start that no
351+
# Unfortunately at this point the interpreter is in a state that no
352352
# longer allows us to spawn a thread and we have to bail.
353353
self._running = False
354354
return False

sentry_sdk/monitor.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ def __init__(self, transport, interval=10):
3737

3838
def _ensure_running(self):
3939
# type: () -> None
40+
"""
41+
Check that the monitor has an active thread to run in, or create one if not.
42+
43+
Note that this might fail (e.g. in Python 3.12 it's not possible to
44+
spawn new threads at interpreter shutdown). In that case self._running
45+
will be False after running this function.
46+
"""
4047
if self._thread_for_pid == os.getpid() and self._thread is not None:
4148
return None
4249

@@ -53,7 +60,14 @@ def _thread():
5360

5461
thread = Thread(name=self.name, target=_thread)
5562
thread.daemon = True
56-
thread.start()
63+
try:
64+
thread.start()
65+
except RuntimeError:
66+
# Unfortunately at this point the interpreter is in a state that no
67+
# longer allows us to spawn a thread and we have to bail.
68+
self._running = False
69+
return None
70+
5771
self._thread = thread
5872
self._thread_for_pid = os.getpid()
5973

sentry_sdk/profiler.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,14 @@ def teardown(self):
898898

899899
def ensure_running(self):
900900
# type: () -> None
901+
"""
902+
Check that the profiler has an active thread to run in, and start one if
903+
that's not the case.
904+
905+
Note that this might fail (e.g. in Python 3.12 it's not possible to
906+
spawn new threads at interpreter shutdown). In that case self.running
907+
will be False after running this function.
908+
"""
901909
pid = os.getpid()
902910

903911
# is running on the right process
@@ -918,7 +926,14 @@ def ensure_running(self):
918926
# can keep the application running after other threads
919927
# have exited
920928
self.thread = threading.Thread(name=self.name, target=self.run, daemon=True)
921-
self.thread.start()
929+
try:
930+
self.thread.start()
931+
except RuntimeError:
932+
# Unfortunately at this point the interpreter is in a state that no
933+
# longer allows us to spawn a thread and we have to bail.
934+
self.running = False
935+
self.thread = None
936+
return
922937

923938
def run(self):
924939
# type: () -> None
@@ -1004,7 +1019,14 @@ def ensure_running(self):
10041019
self.running = True
10051020

10061021
self.thread = ThreadPool(1)
1007-
self.thread.spawn(self.run)
1022+
try:
1023+
self.thread.spawn(self.run)
1024+
except RuntimeError:
1025+
# Unfortunately at this point the interpreter is in a state that no
1026+
# longer allows us to spawn a thread and we have to bail.
1027+
self.running = False
1028+
self.thread = None
1029+
return
10081030

10091031
def run(self):
10101032
# type: () -> None

sentry_sdk/sessions.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ def flush(self):
105105

106106
def _ensure_running(self):
107107
# type: (...) -> None
108+
"""
109+
Check that we have an active thread to run in, or create one if not.
110+
111+
Note that this might fail (e.g. in Python 3.12 it's not possible to
112+
spawn new threads at interpreter shutdown). In that case self._running
113+
will be False after running this function.
114+
"""
108115
if self._thread_for_pid == os.getpid() and self._thread is not None:
109116
return None
110117
with self._thread_lock:
@@ -120,9 +127,17 @@ def _thread():
120127

121128
thread = Thread(target=_thread)
122129
thread.daemon = True
123-
thread.start()
130+
try:
131+
thread.start()
132+
except RuntimeError:
133+
# Unfortunately at this point the interpreter is in a state that no
134+
# longer allows us to spawn a thread and we have to bail.
135+
self._running = False
136+
return None
137+
124138
self._thread = thread
125139
self._thread_for_pid = os.getpid()
140+
126141
return None
127142

128143
def add_aggregate_session(

tests/test_monitor.py

+20
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
from sentry_sdk import Hub, start_transaction
44
from sentry_sdk.transport import Transport
55

6+
try:
7+
from unittest import mock # python 3.3 and above
8+
except ImportError:
9+
import mock # python < 3.3
10+
611

712
class HealthyTestTransport(Transport):
813
def _send_event(self, event):
@@ -82,3 +87,18 @@ def test_transaction_uses_downsampled_rate(
8287
assert transaction.sample_rate == 0.5
8388

8489
assert reports == [("backpressure", "transaction")]
90+
91+
92+
def test_monitor_no_thread_on_shutdown_no_errors(sentry_init):
93+
sentry_init(transport=HealthyTestTransport())
94+
95+
# make it seem like the interpreter is shutting down
96+
with mock.patch(
97+
"threading.Thread.start",
98+
side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
99+
):
100+
monitor = Hub.current.client.monitor
101+
assert monitor is not None
102+
assert monitor._thread is None
103+
monitor.run()
104+
assert monitor._thread is None

tests/test_profiler.py

+45
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,51 @@ def test_thread_scheduler_single_background_thread(scheduler_class):
661661
assert len(get_scheduler_threads(scheduler)) == 0
662662

663663

664+
@requires_python_version(3, 3)
665+
@pytest.mark.parametrize(
666+
("scheduler_class",),
667+
[
668+
pytest.param(ThreadScheduler, id="thread scheduler"),
669+
pytest.param(
670+
GeventScheduler,
671+
marks=[
672+
requires_gevent,
673+
pytest.mark.skip(
674+
reason="cannot find this thread via threading.enumerate()"
675+
),
676+
],
677+
id="gevent scheduler",
678+
),
679+
],
680+
)
681+
def test_thread_scheduler_no_thread_on_shutdown(scheduler_class):
682+
scheduler = scheduler_class(frequency=1000)
683+
684+
# not yet setup, no scheduler threads yet
685+
assert len(get_scheduler_threads(scheduler)) == 0
686+
687+
scheduler.setup()
688+
689+
# setup but no profiles started so still no threads
690+
assert len(get_scheduler_threads(scheduler)) == 0
691+
692+
# mock RuntimeError as if the 3.12 intepreter was shutting down
693+
with mock.patch(
694+
"threading.Thread.start",
695+
side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
696+
):
697+
scheduler.ensure_running()
698+
699+
assert scheduler.running is False
700+
701+
# still no thread
702+
assert len(get_scheduler_threads(scheduler)) == 0
703+
704+
scheduler.teardown()
705+
706+
assert len(get_scheduler_threads(scheduler)) == 0
707+
708+
664709
@requires_python_version(3, 3)
665710
@pytest.mark.parametrize(
666711
("scheduler_class",),

tests/test_sessions.py

+34
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
from sentry_sdk import Hub
44
from sentry_sdk.sessions import auto_session_tracking
55

6+
try:
7+
from unittest import mock # python 3.3 and above
8+
except ImportError:
9+
import mock # python < 3.3
10+
611

712
def sorted_aggregates(item):
813
aggregates = item["aggregates"]
@@ -119,3 +124,32 @@ def test_aggregates_explicitly_disabled_session_tracking_request_mode(
119124
assert len(aggregates) == 1
120125
assert aggregates[0]["exited"] == 1
121126
assert "errored" not in aggregates[0]
127+
128+
129+
def test_no_thread_on_shutdown_no_errors(sentry_init):
130+
sentry_init(
131+
release="fun-release",
132+
environment="not-fun-env",
133+
)
134+
135+
hub = Hub.current
136+
137+
# make it seem like the interpreter is shutting down
138+
with mock.patch(
139+
"threading.Thread.start",
140+
side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
141+
):
142+
with auto_session_tracking(session_mode="request"):
143+
with sentry_sdk.push_scope():
144+
try:
145+
raise Exception("all is wrong")
146+
except Exception:
147+
sentry_sdk.capture_exception()
148+
149+
with auto_session_tracking(session_mode="request"):
150+
pass
151+
152+
hub.start_session(session_mode="request")
153+
hub.end_session()
154+
155+
sentry_sdk.flush()

tests/test_transport.py

+19
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
from sentry_sdk.envelope import Envelope, parse_json
1919
from sentry_sdk.integrations.logging import LoggingIntegration
2020

21+
try:
22+
from unittest import mock # python 3.3 and above
23+
except ImportError:
24+
import mock # python < 3.3
2125

2226
CapturedData = namedtuple("CapturedData", ["path", "event", "envelope", "compressed"])
2327

@@ -165,6 +169,21 @@ def test_transport_infinite_loop(capturing_server, request, make_client):
165169
assert len(capturing_server.captured) == 1
166170

167171

172+
def test_transport_no_thread_on_shutdown_no_errors(capturing_server, make_client):
173+
client = make_client()
174+
175+
# make it seem like the interpreter is shutting down
176+
with mock.patch(
177+
"threading.Thread.start",
178+
side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
179+
):
180+
with Hub(client):
181+
capture_message("hi")
182+
183+
# nothing exploded but also no events can be sent anymore
184+
assert len(capturing_server.captured) == 0
185+
186+
168187
NOW = datetime(2014, 6, 2)
169188

170189

0 commit comments

Comments
 (0)