# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
757 lines
26 KiB
Python
757 lines
26 KiB
Python
"""Tests for agent/deadline.py — the unified deadline layer (#85125).
|
|
|
|
Covers:
|
|
* clamp_timeout normalization (None / non-positive / oversized / NaN / junk)
|
|
* resolve_timeout precedence: config.yaml ``timeouts:`` > legacy env var > default
|
|
* run_bounded_sync: completion, exception propagation, timeout + on_timeout
|
|
* run_bounded_async: completion, exception propagation, timeout + abandonment
|
|
of cancellation-shielded tasks, on_abandon cleanup
|
|
* kill_process_tree: descendants of a session-leader child die with it (POSIX)
|
|
* backward-compat contract of tool_executor._resolve_concurrent_tool_timeout
|
|
after its migration onto resolve_timeout
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from agent.deadline import (
|
|
MAX_SAFE_TIMEOUT_S,
|
|
clamp_timeout,
|
|
kill_process_tree,
|
|
resolve_timeout,
|
|
run_bounded_async,
|
|
run_bounded_sync,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# clamp_timeout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClampTimeout:
|
|
def test_none_stays_none(self):
|
|
assert clamp_timeout(None) is None
|
|
|
|
def test_zero_and_negative_mean_unbounded(self):
|
|
assert clamp_timeout(0) is None
|
|
assert clamp_timeout(-5) is None
|
|
|
|
def test_normal_value_passes_through(self):
|
|
assert clamp_timeout(420.0) == 420.0
|
|
|
|
def test_oversized_value_clamped_to_platform_safe_max(self):
|
|
# The #83220 class: >time_t deadlines crash Lock.acquire on macOS.
|
|
assert clamp_timeout(10**18) == MAX_SAFE_TIMEOUT_S
|
|
|
|
def test_clamped_value_safe_for_threading_primitives(self):
|
|
# Regression proof for #83220: the clamped value itself must be
|
|
# accepted by the exact primitive that used to overflow. Acquiring an
|
|
# uncontended lock returns immediately regardless of timeout, so
|
|
# passing the full clamped value is safe and actually exercises the
|
|
# time_t conversion.
|
|
big = clamp_timeout(float(10**15))
|
|
assert big is not None
|
|
lock = threading.Lock()
|
|
assert lock.acquire(timeout=big)
|
|
lock.release()
|
|
|
|
def test_nan_and_junk_treated_as_unbounded(self):
|
|
assert clamp_timeout(float("nan")) is None
|
|
assert clamp_timeout("not-a-number") is None # type: ignore[arg-type]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_timeout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveTimeout:
|
|
def test_default_wins_when_nothing_configured(self, monkeypatch):
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.delenv("HERMES_TEST_DEADLINE_X", raising=False)
|
|
assert (
|
|
resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X")
|
|
== 42.0
|
|
)
|
|
|
|
def test_env_var_beats_default(self, monkeypatch):
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "17.5")
|
|
assert (
|
|
resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X")
|
|
== 17.5
|
|
)
|
|
|
|
def test_config_beats_env_var(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section", lambda: {"a": {"b": 99}}
|
|
)
|
|
monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "17.5")
|
|
assert (
|
|
resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X")
|
|
== 99.0
|
|
)
|
|
|
|
def test_dotted_key_walks_nested_maps(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section",
|
|
lambda: {"tools": {"concurrent_batch": 300}},
|
|
)
|
|
assert resolve_timeout("tools.concurrent_batch", default=420.0) == 300.0
|
|
|
|
def test_zero_config_value_means_unbounded(self, monkeypatch):
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {"a": {"b": 0}})
|
|
assert resolve_timeout("a.b", default=42.0) is None
|
|
|
|
def test_invalid_config_value_falls_through_to_env(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section", lambda: {"a": {"b": "soon"}}
|
|
)
|
|
monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "17.5")
|
|
assert (
|
|
resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X")
|
|
== 17.5
|
|
)
|
|
|
|
def test_invalid_env_value_falls_through_to_default(self, monkeypatch):
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "banana")
|
|
assert (
|
|
resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X")
|
|
== 42.0
|
|
)
|
|
|
|
def test_bool_config_value_rejected(self, monkeypatch):
|
|
# YAML `true` must not silently become a 1-second deadline.
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section", lambda: {"a": {"b": True}}
|
|
)
|
|
assert resolve_timeout("a.b", default=42.0) == 42.0
|
|
|
|
def test_nan_config_value_falls_through(self, monkeypatch):
|
|
# NaN must fall through to the next source, not resolve as unbounded.
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section", lambda: {"a": {"b": float("nan")}}
|
|
)
|
|
assert resolve_timeout("a.b", default=42.0) == 42.0
|
|
|
|
def test_broken_config_read_never_breaks_the_protected_path(self, monkeypatch):
|
|
# _timeouts_section swallows config-load failures internally; prove
|
|
# the public contract by making the underlying loader raise.
|
|
import agent.deadline as dl
|
|
|
|
def _boom():
|
|
raise RuntimeError("config unreadable")
|
|
|
|
monkeypatch.setattr("hermes_cli.config.load_config_readonly", _boom)
|
|
assert dl._timeouts_section() == {}
|
|
assert resolve_timeout("a.b", default=5.0) == 5.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_bounded_sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunBoundedSync:
|
|
def test_completion_returns_value(self):
|
|
result = run_bounded_sync(lambda: "ok", 5.0, label="t")
|
|
assert result.timed_out is False
|
|
assert result.value == "ok"
|
|
|
|
def test_unbounded_when_timeout_none(self):
|
|
result = run_bounded_sync(lambda: 7, None, label="t")
|
|
assert result.timed_out is False and result.value == 7
|
|
|
|
def test_exception_propagates_unchanged(self):
|
|
class Boom(RuntimeError):
|
|
pass
|
|
|
|
with pytest.raises(Boom):
|
|
run_bounded_sync(lambda: (_ for _ in ()).throw(Boom("x")), 5.0, label="t")
|
|
|
|
def test_timeout_abandons_worker_and_reports(self):
|
|
release = threading.Event()
|
|
|
|
def _wedged():
|
|
release.wait(30)
|
|
return "late"
|
|
|
|
start = time.monotonic()
|
|
result = run_bounded_sync(_wedged, 0.2, label="wedged")
|
|
elapsed = time.monotonic() - start
|
|
assert result.timed_out is True
|
|
assert result.value is None
|
|
assert elapsed < 5.0 # returned near the deadline, not after 30s
|
|
assert result.label == "wedged"
|
|
release.set()
|
|
|
|
def test_on_timeout_callback_runs(self):
|
|
release = threading.Event()
|
|
fired = []
|
|
result = run_bounded_sync(
|
|
lambda: release.wait(30),
|
|
0.1,
|
|
label="t",
|
|
on_timeout=lambda: fired.append(True),
|
|
)
|
|
assert result.timed_out and fired == [True]
|
|
release.set()
|
|
|
|
def test_on_timeout_callback_failure_is_swallowed(self):
|
|
release = threading.Event()
|
|
result = run_bounded_sync(
|
|
lambda: release.wait(30),
|
|
0.1,
|
|
label="t",
|
|
on_timeout=lambda: (_ for _ in ()).throw(RuntimeError("cleanup boom")),
|
|
)
|
|
assert result.timed_out is True
|
|
release.set()
|
|
|
|
def test_keyboard_interrupt_lands_before_full_deadline(self):
|
|
"""Sliced Event.wait must observe SetAsyncExc within one poll slice."""
|
|
release = threading.Event()
|
|
holder: dict = {}
|
|
|
|
def _run():
|
|
try:
|
|
holder["result"] = run_bounded_sync(
|
|
lambda: release.wait(30),
|
|
10.0,
|
|
label="ki",
|
|
)
|
|
except KeyboardInterrupt:
|
|
holder["exc"] = "KeyboardInterrupt"
|
|
|
|
t = threading.Thread(target=_run)
|
|
t.start()
|
|
time.sleep(0.15)
|
|
import ctypes
|
|
|
|
assert t.ident is not None
|
|
ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
ctypes.c_ulong(t.ident), ctypes.py_object(KeyboardInterrupt),
|
|
)
|
|
assert ret == 1
|
|
t.join(timeout=2.0)
|
|
release.set()
|
|
assert not t.is_alive()
|
|
assert holder.get("exc") == "KeyboardInterrupt"
|
|
|
|
|
|
def test_worker_inherits_caller_contextvars(self):
|
|
"""Profile secret scope / session id must survive the thread hop."""
|
|
import contextvars
|
|
|
|
var = contextvars.ContextVar("deadline_sync_ctx")
|
|
token = var.set("from-caller")
|
|
try:
|
|
result = run_bounded_sync(lambda: var.get(None), 5.0, label="ctx")
|
|
finally:
|
|
var.reset(token)
|
|
assert result.timed_out is False
|
|
assert result.value == "from-caller"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_bounded_async
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunBoundedAsync:
|
|
def test_completion_returns_value(self):
|
|
async def scenario():
|
|
async def op():
|
|
return "ok"
|
|
|
|
return await run_bounded_async(op(), 5.0, label="t")
|
|
|
|
result = asyncio.run(scenario())
|
|
assert result.timed_out is False and result.value == "ok"
|
|
|
|
def test_unbounded_when_timeout_none(self):
|
|
async def scenario():
|
|
async def op():
|
|
return 7
|
|
|
|
return await run_bounded_async(op(), None, label="t")
|
|
|
|
result = asyncio.run(scenario())
|
|
assert result.timed_out is False and result.value == 7
|
|
|
|
def test_exception_propagates_unchanged(self):
|
|
class Boom(RuntimeError):
|
|
pass
|
|
|
|
async def scenario():
|
|
async def op():
|
|
raise Boom("x")
|
|
|
|
await run_bounded_async(op(), 5.0, label="t")
|
|
|
|
with pytest.raises(Boom):
|
|
asyncio.run(scenario())
|
|
|
|
def test_timeout_returns_promptly(self):
|
|
async def scenario():
|
|
async def op():
|
|
await asyncio.sleep(30)
|
|
|
|
start = time.monotonic()
|
|
result = await run_bounded_async(op(), 0.2, label="slow")
|
|
return result, time.monotonic() - start
|
|
|
|
result, elapsed = asyncio.run(scenario())
|
|
assert result.timed_out is True
|
|
assert elapsed < 5.0
|
|
|
|
def test_timeout_abandons_cancellation_shielded_task(self):
|
|
"""The family-A killer case: asyncio.wait_for cannot expire a shielded
|
|
scope; the thread-timer deadline must return anyway."""
|
|
|
|
async def scenario():
|
|
hung = asyncio.Event()
|
|
|
|
async def inner():
|
|
await hung.wait()
|
|
|
|
async def shielded():
|
|
# Shield swallows the cancellation run_bounded_async issues.
|
|
await asyncio.shield(asyncio.ensure_future(inner()))
|
|
|
|
start = time.monotonic()
|
|
result = await run_bounded_async(shielded(), 0.2, label="shielded")
|
|
elapsed = time.monotonic() - start
|
|
hung.set() # release the orphan so the loop can drain
|
|
await asyncio.sleep(0)
|
|
return result, elapsed
|
|
|
|
result, elapsed = asyncio.run(scenario())
|
|
assert result.timed_out is True
|
|
assert elapsed < 5.0
|
|
|
|
def test_on_abandon_cleanup_runs_detached(self):
|
|
async def scenario():
|
|
cleaned = asyncio.Event()
|
|
|
|
async def _cleanup():
|
|
cleaned.set()
|
|
|
|
async def op():
|
|
await asyncio.sleep(30)
|
|
|
|
result = await run_bounded_async(op(), 0.1, label="t", on_abandon=_cleanup)
|
|
await asyncio.wait_for(cleaned.wait(), timeout=5.0)
|
|
return result
|
|
|
|
result = asyncio.run(scenario())
|
|
assert result.timed_out is True
|
|
|
|
def test_completed_op_never_reports_timeout(self):
|
|
# Race guard: completion just under the deadline must report success.
|
|
async def scenario():
|
|
async def op():
|
|
await asyncio.sleep(0.01)
|
|
return "made it"
|
|
|
|
return await run_bounded_async(op(), 5.0, label="t")
|
|
|
|
result = asyncio.run(scenario())
|
|
assert result.timed_out is False and result.value == "made it"
|
|
|
|
def test_external_cancellation_cancels_inner_task(self):
|
|
# If the CALLER cancels run_bounded_async, the inner task must not be
|
|
# leaked running unobserved.
|
|
async def scenario():
|
|
started = asyncio.Event()
|
|
inner_cancelled = asyncio.Event()
|
|
|
|
async def op():
|
|
started.set()
|
|
try:
|
|
await asyncio.sleep(30)
|
|
except asyncio.CancelledError:
|
|
inner_cancelled.set()
|
|
raise
|
|
|
|
outer = asyncio.ensure_future(run_bounded_async(op(), 25.0, label="t"))
|
|
await started.wait()
|
|
outer.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await outer
|
|
await asyncio.wait_for(inner_cancelled.wait(), timeout=5.0)
|
|
return True
|
|
|
|
assert asyncio.run(scenario()) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# kill_process_tree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _wait_pid_dead(pid: int, timeout: float = 5.0) -> bool:
|
|
"""True once *pid* is gone or a zombie (killed, not yet reaped)."""
|
|
import psutil
|
|
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
try:
|
|
if psutil.Process(pid).status() == psutil.STATUS_ZOMBIE:
|
|
return True
|
|
except psutil.NoSuchProcess:
|
|
return True
|
|
time.sleep(0.02)
|
|
return False
|
|
|
|
|
|
@pytest.mark.platforms("posix") # POSIX process-group semantics
|
|
class TestKillProcessTree:
|
|
def test_kills_descendants_of_session_leader(self, tmp_path):
|
|
"""A child spawned with start_new_session must die with its own child.
|
|
|
|
This is the orphan-tree class (#71148): killing only the direct child
|
|
leaves grandchildren running.
|
|
"""
|
|
started = tmp_path / "grandchild_started"
|
|
grandchild_py = tmp_path / "grandchild.py"
|
|
grandchild_py.write_text(
|
|
"import os, pathlib, time\n"
|
|
f"pathlib.Path({str(started)!r}).write_text(str(os.getpid()))\n"
|
|
"time.sleep(10)\n"
|
|
)
|
|
parent_py = tmp_path / "parent.py"
|
|
parent_py.write_text(
|
|
"import subprocess, sys, time\n"
|
|
f"subprocess.Popen([sys.executable, {str(grandchild_py)!r}])\n"
|
|
"time.sleep(10)\n"
|
|
)
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(parent_py)], start_new_session=True
|
|
)
|
|
deadline = time.monotonic() + 10
|
|
while not (started.exists() and started.read_text()) and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
assert started.exists(), "grandchild never spawned — test harness broken"
|
|
assert kill_process_tree(proc.pid) is True
|
|
proc.wait(timeout=5)
|
|
# Grandchild must be dead too (gone, or a zombie awaiting reaping).
|
|
assert _wait_pid_dead(int(started.read_text())), "grandchild survived"
|
|
|
|
def test_kills_descendant_in_its_own_session(self, tmp_path):
|
|
"""A descendant that setsid'd out of the parent's group must die too.
|
|
|
|
killpg on the parent's group cannot reach it; the psutil descendant
|
|
sweep must (tools/environments/base.py documents user commands doing
|
|
exactly this).
|
|
"""
|
|
started = tmp_path / "setsid_grandchild_started"
|
|
grandchild_py = tmp_path / "grandchild.py"
|
|
grandchild_py.write_text(
|
|
"import os, pathlib, time\n"
|
|
f"pathlib.Path({str(started)!r}).write_text(str(os.getpid()))\n"
|
|
"time.sleep(10)\n"
|
|
)
|
|
parent_py = tmp_path / "parent.py"
|
|
parent_py.write_text(
|
|
"import subprocess, sys, time\n"
|
|
# grandchild leaves the parent's session/group entirely
|
|
f"subprocess.Popen([sys.executable, {str(grandchild_py)!r}], start_new_session=True)\n"
|
|
"time.sleep(10)\n"
|
|
)
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(parent_py)], start_new_session=True
|
|
)
|
|
deadline = time.monotonic() + 10
|
|
while not (started.exists() and started.read_text()) and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
assert started.exists(), "grandchild never spawned — test harness broken"
|
|
assert kill_process_tree(proc.pid) is True
|
|
proc.wait(timeout=5)
|
|
# Grandchild must be dead too (gone, or a zombie awaiting reaping).
|
|
assert _wait_pid_dead(int(started.read_text())), "grandchild survived"
|
|
|
|
def test_already_dead_pid_returns_false(self):
|
|
proc = subprocess.Popen([sys.executable, "-c", "pass"], start_new_session=True)
|
|
proc.wait(timeout=10) # reaped: PID is gone from the process table
|
|
assert kill_process_tree(proc.pid) is False
|
|
|
|
def test_non_group_leader_falls_back_to_single_kill(self):
|
|
# Child in OUR process group: killpg would signal the test runner.
|
|
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
|
|
try:
|
|
assert os.getpgid(proc.pid) != proc.pid # not a leader
|
|
assert kill_process_tree(proc.pid, sig=signal.SIGTERM) is True
|
|
proc.wait(timeout=5)
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tool_executor migration contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConcurrentToolTimeoutMigration:
|
|
"""_resolve_concurrent_tool_timeout keeps its exact legacy contract."""
|
|
|
|
def _resolver(self):
|
|
from agent import tool_executor
|
|
|
|
return tool_executor._resolve_concurrent_tool_timeout
|
|
|
|
|
|
def test_env_var_still_works(self, monkeypatch):
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "60")
|
|
assert self._resolver()() == 60.0
|
|
|
|
def test_env_zero_still_disables(self, monkeypatch):
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0")
|
|
assert self._resolver()() is None
|
|
|
|
|
|
def test_new_config_key_wins(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section",
|
|
lambda: {"tools": {"concurrent_batch": 300}},
|
|
)
|
|
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "60")
|
|
assert self._resolver()() == 300.0
|
|
|
|
|
|
class TestSequentialToolTimeoutResolver:
|
|
"""_resolve_sequential_tool_timeout: own key, inherits concurrent default."""
|
|
|
|
def _resolver(self):
|
|
from agent import tool_executor
|
|
|
|
return tool_executor._resolve_sequential_tool_timeout
|
|
|
|
def test_inherits_concurrent_default(self, monkeypatch):
|
|
from agent import tool_executor
|
|
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.delenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", raising=False)
|
|
assert self._resolver()() == tool_executor._resolve_concurrent_tool_timeout()
|
|
|
|
def test_inherits_concurrent_env_bridge(self, monkeypatch):
|
|
# No sequential-specific setting -> concurrent env var flows through.
|
|
monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {})
|
|
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "60")
|
|
assert self._resolver()() == 60.0
|
|
|
|
def test_own_config_key_wins_over_concurrent(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section",
|
|
lambda: {"tools": {"concurrent_batch": 300, "sequential_call": 90}},
|
|
)
|
|
assert self._resolver()() == 90.0
|
|
|
|
def test_zero_disables_independently(self, monkeypatch):
|
|
# Sequential bound can be disabled while the concurrent one stays on.
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section",
|
|
lambda: {"tools": {"concurrent_batch": 300, "sequential_call": 0}},
|
|
)
|
|
assert self._resolver()() is None
|
|
|
|
def test_concurrent_disabled_flows_through(self, monkeypatch):
|
|
# concurrent disabled (None default) + no sequential key -> unbounded.
|
|
monkeypatch.setattr(
|
|
"agent.deadline._timeouts_section",
|
|
lambda: {"tools": {"concurrent_batch": 0}},
|
|
)
|
|
assert self._resolver()() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3a (#85125): SuspectableBackend — poisoned-state contract.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class _RecordingBackend:
|
|
"""Minimal SuspectableBackend: records mark_suspect calls."""
|
|
|
|
def __init__(self) -> None:
|
|
self.reasons: list[str] = []
|
|
|
|
def mark_suspect(self, reason: str) -> None:
|
|
self.reasons.append(reason)
|
|
|
|
def ensure_healthy(self) -> bool:
|
|
return not self.reasons
|
|
|
|
|
|
def test_async_timeout_marks_backend_once():
|
|
from agent.deadline import run_bounded_async
|
|
|
|
async def never():
|
|
await asyncio.Event().wait()
|
|
|
|
async def drive():
|
|
backend = _RecordingBackend()
|
|
result = await run_bounded_async(
|
|
never(), 0.05, label="phase3a", backend=backend
|
|
)
|
|
assert result.timed_out
|
|
return backend
|
|
|
|
backend = asyncio.run(drive())
|
|
assert len(backend.reasons) == 1
|
|
assert "phase3a" in backend.reasons[0]
|
|
|
|
|
|
def test_async_completion_never_marks_backend():
|
|
from agent.deadline import run_bounded_async
|
|
|
|
async def quick():
|
|
return "done"
|
|
|
|
async def drive():
|
|
backend = _RecordingBackend()
|
|
result = await run_bounded_async(
|
|
quick(), 5.0, label="phase3a-ok", backend=backend
|
|
)
|
|
assert not result.timed_out and result.value == "done"
|
|
return backend
|
|
|
|
backend = asyncio.run(drive())
|
|
assert backend.reasons == []
|
|
|
|
|
|
def test_sync_timeout_marks_backend_once():
|
|
from agent.deadline import run_bounded_sync
|
|
|
|
def block():
|
|
time.sleep(10)
|
|
|
|
backend = _RecordingBackend()
|
|
result = run_bounded_sync(block, 0.05, label="phase3a-sync", backend=backend)
|
|
assert result.timed_out
|
|
assert len(backend.reasons) == 1
|
|
assert "phase3a-sync" in backend.reasons[0]
|
|
|
|
|
|
def test_non_adopting_backend_cannot_weaken_the_bound():
|
|
"""A backend without mark_suspect still gets a real timeout result."""
|
|
|
|
class PlainBackend:
|
|
pass
|
|
|
|
async def never():
|
|
await asyncio.Event().wait()
|
|
|
|
async def drive():
|
|
from agent.deadline import run_bounded_async
|
|
|
|
return await run_bounded_async(
|
|
never(), 0.05, label="phase3a-plain", backend=PlainBackend()
|
|
)
|
|
|
|
result = asyncio.run(drive())
|
|
assert result.timed_out
|
|
assert result.label == "phase3a-plain"
|
|
|
|
|
|
def test_mark_suspect_raising_never_corrupts_the_result():
|
|
"""A broken mark_suspect must not eat the timeout or the reason."""
|
|
|
|
class ExplodingBackend:
|
|
def mark_suspect(self, reason: str) -> None:
|
|
raise RuntimeError("backend is broken")
|
|
|
|
async def never():
|
|
await asyncio.Event().wait()
|
|
|
|
async def drive():
|
|
from agent.deadline import run_bounded_async
|
|
|
|
return await run_bounded_async(
|
|
never(), 0.05, label="phase3a-boom", backend=ExplodingBackend()
|
|
)
|
|
|
|
result = asyncio.run(drive())
|
|
assert result.timed_out
|
|
assert result.label == "phase3a-boom"
|
|
|
|
|
|
def test_sync_completion_never_marks_backend():
|
|
from agent.deadline import run_bounded_sync
|
|
|
|
backend = _RecordingBackend()
|
|
result = run_bounded_sync(
|
|
lambda: "ok", 5.0, label="phase3a-sync-ok", backend=backend
|
|
)
|
|
assert not result.timed_out and result.value == "ok"
|
|
assert backend.reasons == []
|
|
|
|
|
|
def test_sync_mark_happens_before_on_timeout():
|
|
"""The review-round ordering contract: mark BEFORE owner cleanup, so a
|
|
recycle in on_timeout never sees an unmarked backend (and a healed
|
|
replacement never inherits a stale flag)."""
|
|
from agent.deadline import run_bounded_sync
|
|
|
|
backend = _RecordingBackend()
|
|
seen_at_cleanup: list[int] = []
|
|
|
|
def on_timeout():
|
|
seen_at_cleanup.append(len(backend.reasons))
|
|
|
|
result = run_bounded_sync(
|
|
lambda: time.sleep(10),
|
|
0.05,
|
|
label="phase3a-order-sync",
|
|
on_timeout=on_timeout,
|
|
backend=backend,
|
|
)
|
|
assert result.timed_out
|
|
assert seen_at_cleanup == [1] # mark already applied when cleanup ran
|
|
|
|
|
|
def test_async_mark_happens_before_on_abandon_cleanup():
|
|
"""Pins the scheduling invariant the inline mark relies on: on_abandon
|
|
is ensure_future'd (can't start until the next loop tick), so the
|
|
synchronous mark always lands first. An offloaded (to_thread) mark
|
|
would break this — this test is the guard against that 'fix'."""
|
|
from agent.deadline import run_bounded_async
|
|
|
|
backend = _RecordingBackend()
|
|
seen_at_cleanup: list[int] = []
|
|
cleaned = asyncio.Event()
|
|
|
|
async def _cleanup():
|
|
seen_at_cleanup.append(len(backend.reasons))
|
|
cleaned.set()
|
|
|
|
async def never():
|
|
await asyncio.Event().wait()
|
|
|
|
async def drive():
|
|
result = await run_bounded_async(
|
|
never(), 0.05, label="phase3a-order-async",
|
|
on_abandon=_cleanup, backend=backend,
|
|
)
|
|
await asyncio.wait_for(cleaned.wait(), timeout=5.0)
|
|
return result
|
|
|
|
result = asyncio.run(drive())
|
|
assert result.timed_out
|
|
assert seen_at_cleanup == [1] # mark already applied when cleanup started
|