diff --git a/.github/actions/azure-functions-integration-setup/action.yml b/.github/actions/azure-functions-integration-setup/action.yml index 28c1c6cd1d..99b8207a82 100644 --- a/.github/actions/azure-functions-integration-setup/action.yml +++ b/.github/actions/azure-functions-integration-setup/action.yml @@ -46,3 +46,18 @@ runs: echo "Installing Azure Functions Core Tools" npm install -g azure-functions-core-tools@4 --unsafe-perm true func --version + - name: Ensure Python 3.12 is available for Azure Functions worker + shell: bash + run: | + # The Azure Functions Python worker may segfault on Python >=3.13 + # (protobuf C extension crash). Ensure a compatible Python is + # available so the conftest can redirect the worker to it. + if ! python3.12 --version 2>/dev/null; then + echo "Python 3.12 not found on system, installing via uv..." + uv python install 3.12 + FUNC_WORKER_PYTHON="$(uv python find 3.12)" + echo "FUNC_WORKER_PYTHON=${FUNC_WORKER_PYTHON}" >> "$GITHUB_ENV" + echo "Installed Python 3.12 at ${FUNC_WORKER_PYTHON}" + else + echo "System Python 3.12 found: $(which python3.12)" + fi diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 56525b442e..ad9258c20f 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -170,7 +170,6 @@ jobs: environment: integration timeout-minutes: 60 env: - UV_PYTHON: "3.10" OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 6d169948db..1ad170a6b0 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -285,7 +285,6 @@ jobs: runs-on: ubuntu-latest environment: integration env: - UV_PYTHON: "3.10" OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} diff --git a/python/packages/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py index 3f6060d93d..acfe59c313 100644 --- a/python/packages/azurefunctions/tests/integration_tests/conftest.py +++ b/python/packages/azurefunctions/tests/integration_tests/conftest.py @@ -350,6 +350,34 @@ def _load_and_validate_env() -> None: ) +def _find_func_worker_python() -> str | None: + """Find a Python 3.10-3.12 executable for the Azure Functions worker. + + The Azure Functions Core Tools worker may segfault on Python >=3.13 due to + protobuf/grpcio C extension compatibility issues (``google._upb`` crash). + This returns a path to a compatible Python interpreter so the function host + can use it instead of the default (which is the test runner's Python). + + Returns ``None`` when the current interpreter is already compatible or no + alternative can be found. + """ + if sys.version_info < (3, 13): + return None # Current Python is compatible; no override needed. + + # Check for an explicit override first (e.g. set by CI). + explicit = os.environ.get("FUNC_WORKER_PYTHON", "").strip() + if explicit and Path(explicit).is_file(): + return explicit + + # Try versioned system executables (python3.12, python3.11, python3.10). + for minor in range(12, 9, -1): + path = shutil.which(f"python3.{minor}") + if path: + return path + + return None + + def _start_function_app(sample_path: Path, port: int) -> subprocess.Popen[Any]: """Start a function app in the specified sample directory. @@ -361,6 +389,13 @@ def _start_function_app(sample_path: Path, port: int) -> subprocess.Popen[Any]: # use the task hub name to separate orchestration state. env["TASKHUB_NAME"] = f"test{uuid.uuid4().hex[:8]}" + # The Azure Functions Python worker may crash on Python >=3.13 with a + # SIGSEGV in the protobuf C extension (google._upb). Point the worker at + # a compatible Python when one is available. + worker_python = _find_func_worker_python() + if worker_python: + env["languageWorkers__python__defaultExecutablePath"] = worker_python + # On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination # shell=True only on Windows to handle PATH resolution if sys.platform == "win32": @@ -371,8 +406,15 @@ def _start_function_app(sample_path: Path, port: int) -> subprocess.Popen[Any]: shell=True, env=env, ) - # On Unix, don't use shell=True to avoid shell wrapper issues - return subprocess.Popen(["func", "start", "--port", str(port)], cwd=str(sample_path), env=env) + # On Unix, use start_new_session=True to isolate the process group from the + # pytest-xdist worker. Without this, signals (e.g. from test-timeout) can + # propagate to the func host and vice-versa, potentially killing the worker. + return subprocess.Popen( + ["func", "start", "--port", str(port)], + cwd=str(sample_path), + env=env, + start_new_session=True, + ) def _wait_for_function_app_ready(func_process: subprocess.Popen[Any], port: int, max_wait: int = 60) -> None: @@ -529,18 +571,33 @@ class TestSample01SingleAgent: _load_and_validate_env() max_attempts = 3 + # The overall budget MUST be shorter than the pytest-timeout value + # (--timeout=120 by default) so that the fixture finishes cleanly instead + # of being killed by os._exit() which crashes the xdist worker. + overall_budget = 100 # seconds – leaves headroom below the 120 s test timeout last_error: Exception | None = None func_process: subprocess.Popen[Any] | None = None base_url = "" port = 0 + overall_start = time.monotonic() + attempts_made = 0 for _ in range(max_attempts): + remaining = overall_budget - (time.monotonic() - overall_start) + if remaining < 10: + # Not enough time for another attempt; bail out. + break + + attempts_made += 1 port = _find_available_port() base_url = _build_base_url(port) func_process = _start_function_app(sample_path, port) try: - _wait_for_function_app_ready(func_process, port) + # Cap each attempt's wait to the remaining budget minus a small + # buffer for cleanup. + per_attempt_wait = min(60, int(remaining) - 5) + _wait_for_function_app_ready(func_process, port, max_wait=max(per_attempt_wait, 10)) last_error = None break except FunctionAppStartupError as exc: @@ -549,7 +606,8 @@ class TestSample01SingleAgent: func_process = None if func_process is None: - error_message = f"Function app failed to start after {max_attempts} attempt(s)." + elapsed = int(time.monotonic() - overall_start) + error_message = f"Function app failed to start after {attempts_made} attempt(s) ({elapsed}s elapsed)." if last_error is not None: error_message += f" Last error: {last_error}" pytest.fail(error_message)