This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 498acc8cf26 Fix timeout_with_traceback crashes on Windows and non-main
threads (#63664)
498acc8cf26 is described below
commit 498acc8cf269e3ec2717eec25483aac7bf344533
Author: Kumar Gautam <[email protected]>
AuthorDate: Thu Apr 2 23:08:53 2026 +0530
Fix timeout_with_traceback crashes on Windows and non-main threads (#63664)
* Fix timeout_with_traceback crashes on Windows and non-main threads
* Add regression tests and newsfragment for timeout_with_traceback fix (fix
lint)
* Fix syntax in newsfragment for timeout_with_traceback
---
airflow-core/newsfragments/63664.bugfix.rst | 1 +
airflow-core/src/airflow/utils/db.py | 19 +++--
airflow-core/tests/unit/utils/test_db_timeout.py | 58 ++++++++++++++
.../tests/unit/utils/test_timeout_traceback.py | 88 ++++++++++++++++++++++
4 files changed, 161 insertions(+), 5 deletions(-)
diff --git a/airflow-core/newsfragments/63664.bugfix.rst
b/airflow-core/newsfragments/63664.bugfix.rst
new file mode 100644
index 00000000000..e759d608418
--- /dev/null
+++ b/airflow-core/newsfragments/63664.bugfix.rst
@@ -0,0 +1 @@
+Fix ``timeout_with_traceback`` crashes on Windows and non-main threads
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index a32644df803..13bbd440327 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -152,15 +152,24 @@ def timeout_with_traceback(seconds, message="Operation
timed out"):
raise TimeoutException(message)
# Set the signal handler
- old_handler = signal.signal(signal.SIGALRM, timeout_handler)
- signal.alarm(seconds)
+ timeout_supported = False
+ try:
+ old_handler = signal.signal(signal.SIGALRM, timeout_handler)
+ signal.alarm(seconds)
+ timeout_supported = True
+ except (AttributeError, ValueError):
+ log.warning(
+ "timeout_with_traceback requires signal.SIGALRM and the main
thread. "
+ "Proceeding without a timeout."
+ )
try:
yield
finally:
- # Cancel the alarm and restore the old handler
- signal.alarm(0)
- signal.signal(signal.SIGALRM, old_handler)
+ if timeout_supported:
+ # Cancel the alarm and restore the old handler
+ signal.alarm(0)
+ signal.signal(signal.SIGALRM, old_handler)
@provide_session
diff --git a/airflow-core/tests/unit/utils/test_db_timeout.py
b/airflow-core/tests/unit/utils/test_db_timeout.py
new file mode 100644
index 00000000000..62fc6591005
--- /dev/null
+++ b/airflow-core/tests/unit/utils/test_db_timeout.py
@@ -0,0 +1,58 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest import mock
+
+import pytest
+
+from airflow.utils.db import timeout_with_traceback
+
+
+class TestTimeoutWithTraceback:
+ def test_timeout_with_traceback_basic(self):
+ """Test that the context manager works normally on supported
systems."""
+ with mock.patch("signal.signal") as mock_signal,
mock.patch("signal.alarm") as mock_alarm:
+ mock_signal.return_value = "old_handler"
+
+ with timeout_with_traceback(10):
+ pass
+
+ # Check signal handler was set
+ mock_signal.assert_any_call(mock.ANY, mock.ANY)
+ mock_alarm.assert_any_call(10)
+
+ # Check cleanup happened
+ mock_alarm.assert_any_call(0)
+ mock_signal.assert_any_call(mock.ANY, "old_handler")
+
+ @pytest.mark.parametrize("exception", [AttributeError, ValueError])
+ def test_timeout_with_traceback_unsupported(self, exception, caplog):
+ """
+ Test that it handles missing SIGALRM or non-main thread gracefully.
+ AttributeError happens on Windows (missing SIGALRM).
+ ValueError happens in non-main threads (signal.signal not allowed).
+ """
+ # Patch BOTH signal.signal and signal.SIGALRM to be safe across
platforms
+ with mock.patch("signal.signal", side_effect=exception),
mock.patch("signal.SIGALRM", create=True):
+ with timeout_with_traceback(10):
+ # Should not raise exception
+ pass
+
+ assert "timeout_with_traceback requires signal.SIGALRM and the
main thread" in caplog.text
+ assert "Proceeding without a timeout" in caplog.text
diff --git a/airflow-core/tests/unit/utils/test_timeout_traceback.py
b/airflow-core/tests/unit/utils/test_timeout_traceback.py
new file mode 100644
index 00000000000..3faf6e98491
--- /dev/null
+++ b/airflow-core/tests/unit/utils/test_timeout_traceback.py
@@ -0,0 +1,88 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import signal
+from unittest import mock
+
+import pytest
+
+from airflow.utils.db import timeout_with_traceback
+
+
+class TestTimeoutWithTraceback:
+ def test_timeout_supported_unix(self):
+ """Test that it works normally on Unix-like systems where SIGALRM is
supported."""
+ if not hasattr(signal, "SIGALRM"):
+ pytest.skip("SIGALRM not supported on this platform")
+
+ with mock.patch("signal.signal") as mock_signal,
mock.patch("signal.alarm") as mock_alarm:
+ mock_signal.return_value = "old_handler"
+
+ with timeout_with_traceback(seconds=5):
+ pass
+
+ mock_signal.assert_any_call(signal.SIGALRM, mock.ANY)
+ mock_alarm.assert_any_call(5)
+ # Cleanup
+ mock_alarm.assert_any_call(0)
+ mock_signal.assert_any_call(signal.SIGALRM, "old_handler")
+
+ @pytest.mark.parametrize(
+ "exception",
+ [
+ pytest.param(AttributeError("SIGALRM missing"),
id="windows_attribute_error"),
+ pytest.param(ValueError("signal only works in main thread"),
id="non_main_thread_value_error"),
+ ],
+ )
+ def test_timeout_unsupported_platforms_or_threads(self, exception):
+ """Test that it handles unsupported platforms (Windows) or non-main
threads gracefully."""
+ # We need to patch signal.signal to raise the exception
+ # Even if SIGALRM exists, we force it to fail to test the catch block
+
+ with (
+ mock.patch("signal.signal", side_effect=exception),
+ mock.patch("airflow.utils.db.log") as mock_log,
+ ):
+ with timeout_with_traceback(seconds=5):
+ # Should not raise any exception
+ pass
+
+ mock_log.warning.assert_called_once_with(
+ "timeout_with_traceback requires signal.SIGALRM and the main
thread. "
+ "Proceeding without a timeout."
+ )
+
+ def test_timeout_happens(self):
+ """Test that it actually raises TimeoutException when time is up."""
+ if not hasattr(signal, "SIGALRM"):
+ pytest.skip("SIGALRM not supported on this platform")
+
+ # We can't easily test real timeout in unit test without blocking
+ # but we can call the handler directly to see if it raises
+
+ with mock.patch("signal.signal") as mock_signal:
+ with timeout_with_traceback(seconds=1):
+ # The handler is the second argument to the first call of
signal.signal
+ handler = mock_signal.call_args_list[0][0][1]
+
+ with pytest.raises(Exception, match="Operation timed out") as
excinfo:
+ handler(signal.SIGALRM, None)
+
+ assert "Operation timed out" in str(excinfo.value)
+ assert excinfo.type.__name__ == "TimeoutException"