c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
114 lines
4.9 KiB
Python
114 lines
4.9 KiB
Python
"""Schema tests for Claude plugin hook config: timeout must be bounded."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
HOOK_CONFIG = REPO_ROOT / ".claude-plugin" / "hooks" / "hooks.json"
|
|
|
|
# Per-event hook-level timeout bounds (seconds): (floor, ceiling).
|
|
#
|
|
# Stop is fire-and-forget for the mine subprocess (_spawn_mine returns
|
|
# after a detached Popen), but the handler also calls _save_diary_direct
|
|
# synchronously, which touches chromadb. 10..30s is generous for that
|
|
# work without leaving room for runaway hangs to freeze the session.
|
|
#
|
|
# PreCompact runs _mine_sync synchronously with a per-target subprocess
|
|
# timeout of 60s in mempalace/hooks_cli.py. The hook-level floor of 60
|
|
# keeps the inner bound from being truncated, and the ceiling of 90
|
|
# bounds the worst case at ~30s above that.
|
|
# SessionEnd backgrounds all of its work in the shell wrapper — the foreground
|
|
# only forks the detached child and returns in milliseconds — so its timeout is
|
|
# a generous backstop on a near-instant operation, not a synchronous-work bound
|
|
# like Stop/PreCompact. A bound is still required (#1465) so a wedged fork can
|
|
# never fall back to the 600s command default.
|
|
EVENT_TIMEOUT_BOUNDS: dict[str, tuple[int, int]] = {
|
|
"Stop": (10, 30),
|
|
"SessionEnd": (5, 30),
|
|
"PreCompact": (60, 90),
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def hook_config() -> dict:
|
|
return json.loads(HOOK_CONFIG.read_text(encoding="utf-8"))
|
|
|
|
|
|
@pytest.mark.parametrize("event", sorted(EVENT_TIMEOUT_BOUNDS))
|
|
def test_plugin_hook_timeout_within_bounds(hook_config: dict, event: str) -> None:
|
|
"""Each declared plugin hook must declare a positive bounded timeout (#1465).
|
|
|
|
Without ``timeout``, Claude Code falls back to the 600s command default
|
|
and a hung ``mempalace hook run`` freezes the interactive session for
|
|
up to ten minutes before being canceled.
|
|
"""
|
|
floor, ceiling = EVENT_TIMEOUT_BOUNDS[event]
|
|
assert event in hook_config.get("hooks", {}), f"missing event {event!r} in hook config"
|
|
entries = hook_config["hooks"][event]
|
|
assert isinstance(entries, list) and entries, f"no entries declared for {event}"
|
|
# Pin cardinality: the plugin config intentionally declares exactly one
|
|
# command per event. A duplicate entry would silently double-fire the
|
|
# hook and pass per-hook bounds, so cardinality drift must fail loudly.
|
|
assert len(entries) == 1, (
|
|
f"{event} expected exactly one entry, found {len(entries)}; "
|
|
"duplicate entries would double-fire the hook"
|
|
)
|
|
for entry in entries:
|
|
sub_hooks = entry.get("hooks")
|
|
assert isinstance(sub_hooks, list) and sub_hooks, (
|
|
f"{event} entry missing non-empty 'hooks' array"
|
|
)
|
|
assert len(sub_hooks) == 1, (
|
|
f"{event} entry expected exactly one hook command, found {len(sub_hooks)}"
|
|
)
|
|
for hook in sub_hooks:
|
|
assert hook.get("type") == "command", (
|
|
f"unexpected hook type for {event}: {hook.get('type')!r}"
|
|
)
|
|
assert "timeout" in hook, f"{event} hook missing 'timeout' key"
|
|
timeout = hook["timeout"]
|
|
# bool subclasses int, so reject it explicitly: True == 1 must fail.
|
|
is_real_int = isinstance(timeout, int) and not isinstance(timeout, bool)
|
|
assert is_real_int and floor <= timeout <= ceiling, (
|
|
f"{event} hook timeout must be an int in [{floor}, {ceiling}]s; got {timeout!r}"
|
|
)
|
|
|
|
|
|
def test_no_unbounded_events_in_plugin_config(hook_config: dict) -> None:
|
|
"""No plugin hook event may ship without an explicit bounds entry.
|
|
|
|
Adding a new event (SessionStart, PreToolUse, etc.) to
|
|
``.claude-plugin/hooks/hooks.json`` without registering bounds in
|
|
``EVENT_TIMEOUT_BOUNDS`` would silently fall back to the 600s
|
|
Claude Code command default and re-introduce the regression.
|
|
"""
|
|
declared_events = set(hook_config.get("hooks", {}).keys())
|
|
bounded_events = set(EVENT_TIMEOUT_BOUNDS)
|
|
unbounded = declared_events - bounded_events
|
|
assert not unbounded, (
|
|
f"plugin hook events without timeout bounds: {sorted(unbounded)}. "
|
|
"Add a (floor, ceiling) entry to EVENT_TIMEOUT_BOUNDS in this test "
|
|
"after deciding the worst-case freeze the event can tolerate."
|
|
)
|
|
|
|
|
|
def test_session_end_hook_uses_background_wrapper(hook_config: dict) -> None:
|
|
"""Claude SessionEnd should use the backgrounding wrapper, not PreCompact."""
|
|
events = hook_config.get("hooks", {})
|
|
|
|
assert "SessionEnd" in events
|
|
assert "PreCompact" in events
|
|
assert events["SessionEnd"] != events["PreCompact"]
|
|
|
|
commands = [
|
|
hook["command"]
|
|
for entry in events["SessionEnd"]
|
|
for hook in entry.get("hooks", [])
|
|
if hook.get("type") == "command"
|
|
]
|
|
|
|
assert any("mempal-session-end-hook.sh" in command for command in commands)
|
|
assert not any("mempal-precompact-hook.sh" in command for command in commands)
|