diff --git a/test_upload_coverage.py b/test_upload_coverage.py index e5336ad..5e706a2 100644 --- a/test_upload_coverage.py +++ b/test_upload_coverage.py @@ -475,6 +475,60 @@ def test_telemetry_failure_does_not_affect_action_exit_code(self): self.assertEqual(0, exit_code) + @mock.patch("upload_coverage.threading.Thread") + def test_telemetry_thread_daemon_and_timeout_on_success(self, mock_thread_class): + mock_thread_instance = mock.Mock() + mock_thread_class.return_value = mock_thread_instance + + opener = mock.Mock(return_value=FakeResponse()) + status_opener = mock.Mock(return_value=FakeResponse()) + stdout = io.StringIO() + + with redirect_stdout(stdout): + upload_coverage.main( + environ=self.base_env, opener=opener, status_opener=status_opener, + ) + + # Verify thread constructor arguments + mock_thread_class.assert_called_once() + kwargs = mock_thread_class.call_args.kwargs + self.assertEqual("telemetry-starting", kwargs.get("name")) + self.assertTrue(kwargs.get("daemon")) + + # Verify join was called with the configured timeout + from status_report import STATUS_TIMEOUT_SECONDS + mock_thread_instance.join.assert_called_once_with(timeout=STATUS_TIMEOUT_SECONDS) + + @mock.patch("upload_coverage.threading.Thread") + def test_telemetry_thread_timeout_on_missing_file(self, mock_thread_class): + mock_thread_instance = mock.Mock() + mock_thread_class.return_value = mock_thread_instance + + env = dict(self.base_env, INPUT_FILE="/nonexistent") + status_opener = mock.Mock(return_value=FakeResponse()) + stdout = io.StringIO() + + with redirect_stdout(stdout): + upload_coverage.main(environ=env, opener=mock.Mock(), status_opener=status_opener) + + from status_report import STATUS_TIMEOUT_SECONDS + mock_thread_instance.join.assert_called_once_with(timeout=STATUS_TIMEOUT_SECONDS) + + @mock.patch("upload_coverage.threading.Thread") + def test_telemetry_thread_timeout_on_invalid_input(self, mock_thread_class): + mock_thread_instance = mock.Mock() + mock_thread_class.return_value = mock_thread_instance + + env = dict(self.base_env, INPUT_LANGUAGE="", REF="", PR_NUMBER="") # will trigger ValueError in build_payload + status_opener = mock.Mock(return_value=FakeResponse()) + stdout = io.StringIO() + + with redirect_stdout(stdout): + upload_coverage.main(environ=env, opener=mock.Mock(), status_opener=status_opener) + + from status_report import STATUS_TIMEOUT_SECONDS + mock_thread_instance.join.assert_called_once_with(timeout=STATUS_TIMEOUT_SECONDS) + if __name__ == "__main__": unittest.main() diff --git a/upload_coverage.py b/upload_coverage.py index e6eee03..cc4536e 100755 --- a/upload_coverage.py +++ b/upload_coverage.py @@ -4,6 +4,7 @@ import json import os import sys +import threading import time import urllib.error import urllib.request @@ -174,23 +175,31 @@ def main( api_url = env.get("GITHUB_API_URL", "https://api.github.com") token = env.get("GH_TOKEN", "") - # Send "starting" telemetry report + # Send "starting" telemetry report in a background thread starting_report = status_report.build_starting_report(env) status_report.save_state("started_at", starting_report.get("started_at", "")) status_report.save_state("starting_report", json.dumps(starting_report)) - status_report.send_status_report( - starting_report, - repository=repository, - api_url=api_url, - token=token, - opener=status_opener, + + telemetry_thread = threading.Thread( + target=status_report.send_status_report, + args=(starting_report,), + kwargs={ + "repository": repository, + "api_url": api_url, + "token": token, + "opener": status_opener, + }, + name="telemetry-starting", + daemon=True, ) + telemetry_thread.start() upload_start = time.monotonic() file_path = env.get("INPUT_FILE", "") if not file_path or not Path(file_path).is_file(): emit_annotation("error", f"Coverage file not found: {file_path}") + telemetry_thread.join(timeout=status_report.STATUS_TIMEOUT_SECONDS) _send_completed_report( starting_report, "user-error", error_type="file_not_found", error_message=f"Coverage file not found: {file_path}", @@ -226,6 +235,7 @@ def main( ) except ValueError as error: emit_annotation("error", str(error)) + telemetry_thread.join(timeout=status_report.STATUS_TIMEOUT_SECONDS) _send_completed_report( starting_report, "user-error", error_type="invalid_input", error_message=str(error), @@ -261,6 +271,7 @@ def main( error_type = f"http_{http_status}" if http_status else "network_error" error_message = _extract_message(body) + telemetry_thread.join(timeout=status_report.STATUS_TIMEOUT_SECONDS) _send_completed_report( starting_report, telemetry_status, upload_duration_ms=upload_duration_ms,