@hunter-harness/workflow-harness 0.2.27 → 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/claude-code/harness-test/checklist.md +15 -0
- package/harness/bundles/general/claude-code/harness-test/reference.md +28 -4
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/codebuddy/harness-test/checklist.md +15 -0
- package/harness/bundles/general/codebuddy/harness-test/reference.md +28 -4
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/codex/harness-test/checklist.md +15 -0
- package/harness/bundles/general/codex/harness-test/reference.md +28 -4
- package/harness/bundles/general/codex/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
- package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/cursor/harness-test/checklist.md +15 -0
- package/harness/bundles/general/cursor/harness-test/reference.md +28 -4
- package/harness/bundles/general/cursor/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
- package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/codex/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
- package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/cursor/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
- package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +87 -0
- package/harness/manifests/general/claude-code.json +34 -26
- package/harness/manifests/general/codebuddy.json +34 -26
- package/harness/manifests/general/codex.json +34 -26
- package/harness/manifests/general/cursor.json +34 -26
- package/harness/manifests/java/claude-code.json +34 -26
- package/harness/manifests/java/codebuddy.json +34 -26
- package/harness/manifests/java/codex.json +34 -26
- package/harness/manifests/java/cursor.json +34 -26
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resource-safe test command runner for Hunter Harness workflows.
|
|
3
|
+
|
|
4
|
+
The runner keeps test commands sequential, lowers their scheduling priority,
|
|
5
|
+
enforces a single active run per project, and tears down the managed process
|
|
6
|
+
tree after every command. Resource-intensive profiles require an explicit
|
|
7
|
+
confirmation outside CI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import ctypes
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
DEFAULT_MAX_WORKERS = 2
|
|
28
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
29
|
+
RESOURCE_INTENSIVE_PROFILES = frozenset({"system", "full"})
|
|
30
|
+
DEFAULT_RESOURCE_MODULES = (
|
|
31
|
+
"test_harness_integration.py",
|
|
32
|
+
"test_harness_service.py",
|
|
33
|
+
)
|
|
34
|
+
DEFAULT_DETACHED_SERVICE_MODULES = ("test_harness_service.py",)
|
|
35
|
+
TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TestModule:
|
|
40
|
+
"""One isolated unittest module."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
path: Path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class CommandResult:
|
|
48
|
+
"""Result of one managed command."""
|
|
49
|
+
|
|
50
|
+
returncode: int
|
|
51
|
+
timed_out: bool
|
|
52
|
+
duration_seconds: float
|
|
53
|
+
process_tree_isolated: bool
|
|
54
|
+
output_tail: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestRunAlreadyActive(RuntimeError):
|
|
58
|
+
"""Raised when another resource-safe runner owns the project lock."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _truthy(value: object) -> bool:
|
|
62
|
+
return str(value or "").strip().lower() in TRUTHY_VALUES
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def bounded_worker_count(
|
|
66
|
+
requested: int,
|
|
67
|
+
environ: Mapping[str, str] | None = None,
|
|
68
|
+
) -> int:
|
|
69
|
+
"""Return a positive worker count that never exceeds the global safe cap."""
|
|
70
|
+
|
|
71
|
+
env = os.environ if environ is None else environ
|
|
72
|
+
raw_limit = env.get("HARNESS_TEST_MAX_WORKERS", str(DEFAULT_MAX_WORKERS))
|
|
73
|
+
try:
|
|
74
|
+
configured = int(raw_limit)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
configured = DEFAULT_MAX_WORKERS
|
|
77
|
+
configured = max(1, min(DEFAULT_MAX_WORKERS, configured))
|
|
78
|
+
return max(1, min(int(requested), configured))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resource_profile_allowed(
|
|
82
|
+
profile: str,
|
|
83
|
+
confirmed: bool,
|
|
84
|
+
environ: Mapping[str, str] | None = None,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Return whether a profile may execute in the current environment."""
|
|
87
|
+
|
|
88
|
+
if profile not in RESOURCE_INTENSIVE_PROFILES:
|
|
89
|
+
return True
|
|
90
|
+
env = os.environ if environ is None else environ
|
|
91
|
+
return bool(
|
|
92
|
+
confirmed
|
|
93
|
+
or _truthy(env.get("CI"))
|
|
94
|
+
or _truthy(env.get("HARNESS_ALLOW_RESOURCE_INTENSIVE_TESTS"))
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def discover_test_modules(tests_dir: Path) -> list[TestModule]:
|
|
99
|
+
"""Discover deterministic top-level unittest modules."""
|
|
100
|
+
|
|
101
|
+
root = Path(tests_dir).resolve()
|
|
102
|
+
if not root.is_dir():
|
|
103
|
+
raise FileNotFoundError(f"test directory does not exist: {root}")
|
|
104
|
+
return [
|
|
105
|
+
TestModule(path.name, path)
|
|
106
|
+
for path in sorted(root.glob("test_*.py"), key=lambda item: item.name)
|
|
107
|
+
if path.is_file()
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_unittest_plan(
|
|
112
|
+
tests_dir: Path,
|
|
113
|
+
profile: str,
|
|
114
|
+
*,
|
|
115
|
+
resource_modules: Sequence[str] = DEFAULT_RESOURCE_MODULES,
|
|
116
|
+
) -> list[TestModule]:
|
|
117
|
+
"""Partition unittest modules into safe, system, or full execution plans."""
|
|
118
|
+
|
|
119
|
+
if profile not in {"safe", "system", "full"}:
|
|
120
|
+
raise ValueError(f"unsupported test profile: {profile}")
|
|
121
|
+
modules = discover_test_modules(tests_dir)
|
|
122
|
+
resource_names = {Path(item).name for item in resource_modules}
|
|
123
|
+
regular = [item for item in modules if item.name not in resource_names]
|
|
124
|
+
intensive = [item for item in modules if item.name in resource_names]
|
|
125
|
+
if profile == "safe":
|
|
126
|
+
return regular
|
|
127
|
+
if profile == "system":
|
|
128
|
+
return intensive
|
|
129
|
+
return [*regular, *intensive]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _pid_is_running(pid: int) -> bool:
|
|
133
|
+
if pid <= 0:
|
|
134
|
+
return False
|
|
135
|
+
if pid == os.getpid():
|
|
136
|
+
return True
|
|
137
|
+
if os.name == "nt":
|
|
138
|
+
process_query_limited_information = 0x1000
|
|
139
|
+
kernel32 = ctypes.windll.kernel32
|
|
140
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
141
|
+
handle = kernel32.OpenProcess(
|
|
142
|
+
process_query_limited_information,
|
|
143
|
+
False,
|
|
144
|
+
pid,
|
|
145
|
+
)
|
|
146
|
+
if not handle:
|
|
147
|
+
return False
|
|
148
|
+
try:
|
|
149
|
+
exit_code = ctypes.c_uint32()
|
|
150
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
151
|
+
return False
|
|
152
|
+
return int(exit_code.value) == 259
|
|
153
|
+
finally:
|
|
154
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
155
|
+
try:
|
|
156
|
+
os.kill(pid, 0)
|
|
157
|
+
except ProcessLookupError:
|
|
158
|
+
return False
|
|
159
|
+
except PermissionError:
|
|
160
|
+
return True
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestRunLock:
|
|
165
|
+
"""Atomic per-project single-instance lock with stale-owner recovery."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, project: Path, *, lock_root: Path | None = None) -> None:
|
|
168
|
+
resolved = str(Path(project).resolve())
|
|
169
|
+
if os.name == "nt":
|
|
170
|
+
resolved = resolved.casefold()
|
|
171
|
+
digest = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:24]
|
|
172
|
+
root = (
|
|
173
|
+
Path(lock_root)
|
|
174
|
+
if lock_root is not None
|
|
175
|
+
else Path(
|
|
176
|
+
os.environ.get(
|
|
177
|
+
"HARNESS_TEST_LOCK_ROOT",
|
|
178
|
+
str(Path(tempfile.gettempdir()) / "hunter-harness-test-locks"),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
self.path = root / f"{digest}.lock"
|
|
183
|
+
self.token = uuid.uuid4().hex
|
|
184
|
+
self._owned = False
|
|
185
|
+
|
|
186
|
+
def _owner(self) -> dict[str, object]:
|
|
187
|
+
try:
|
|
188
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
189
|
+
except (OSError, json.JSONDecodeError):
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
def acquire(self) -> None:
|
|
193
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
for attempt in range(2):
|
|
195
|
+
try:
|
|
196
|
+
descriptor = os.open(
|
|
197
|
+
self.path,
|
|
198
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
199
|
+
0o600,
|
|
200
|
+
)
|
|
201
|
+
except FileExistsError:
|
|
202
|
+
owner = self._owner()
|
|
203
|
+
try:
|
|
204
|
+
owner_pid = int(owner.get("pid", 0))
|
|
205
|
+
except (TypeError, ValueError):
|
|
206
|
+
owner_pid = 0
|
|
207
|
+
try:
|
|
208
|
+
age_seconds = time.time() - self.path.stat().st_mtime
|
|
209
|
+
except OSError:
|
|
210
|
+
age_seconds = 0
|
|
211
|
+
if _pid_is_running(owner_pid) or age_seconds < 30:
|
|
212
|
+
raise TestRunAlreadyActive(
|
|
213
|
+
"TEST_RUN_ALREADY_ACTIVE: "
|
|
214
|
+
f"lock={self.path} ownerPid={owner_pid or 'unknown'}"
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
self.path.unlink()
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
pass
|
|
220
|
+
if attempt == 0:
|
|
221
|
+
continue
|
|
222
|
+
raise TestRunAlreadyActive(
|
|
223
|
+
f"TEST_RUN_ALREADY_ACTIVE: could not reclaim {self.path}"
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
payload = {
|
|
227
|
+
"pid": os.getpid(),
|
|
228
|
+
"token": self.token,
|
|
229
|
+
"startedAtUnix": time.time(),
|
|
230
|
+
}
|
|
231
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
232
|
+
json.dump(payload, stream, ensure_ascii=False)
|
|
233
|
+
stream.flush()
|
|
234
|
+
self._owned = True
|
|
235
|
+
return
|
|
236
|
+
raise TestRunAlreadyActive(f"TEST_RUN_ALREADY_ACTIVE: {self.path}")
|
|
237
|
+
|
|
238
|
+
def release(self) -> None:
|
|
239
|
+
if not self._owned:
|
|
240
|
+
return
|
|
241
|
+
owner = self._owner()
|
|
242
|
+
if owner.get("token") == self.token:
|
|
243
|
+
try:
|
|
244
|
+
self.path.unlink()
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
pass
|
|
247
|
+
self._owned = False
|
|
248
|
+
|
|
249
|
+
def __enter__(self) -> "TestRunLock":
|
|
250
|
+
self.acquire()
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
254
|
+
self.release()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def lower_process_priority() -> bool:
|
|
258
|
+
"""Best-effort scheduling priority reduction inherited by child processes."""
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
if os.name == "nt":
|
|
262
|
+
below_normal_priority_class = 0x00004000
|
|
263
|
+
kernel32 = ctypes.windll.kernel32
|
|
264
|
+
return bool(
|
|
265
|
+
kernel32.SetPriorityClass(
|
|
266
|
+
kernel32.GetCurrentProcess(),
|
|
267
|
+
below_normal_priority_class,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
os.nice(5)
|
|
271
|
+
return True
|
|
272
|
+
except (AttributeError, OSError):
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _WindowsIoCounters(ctypes.Structure):
|
|
277
|
+
_fields_ = [
|
|
278
|
+
("ReadOperationCount", ctypes.c_uint64),
|
|
279
|
+
("WriteOperationCount", ctypes.c_uint64),
|
|
280
|
+
("OtherOperationCount", ctypes.c_uint64),
|
|
281
|
+
("ReadTransferCount", ctypes.c_uint64),
|
|
282
|
+
("WriteTransferCount", ctypes.c_uint64),
|
|
283
|
+
("OtherTransferCount", ctypes.c_uint64),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class _WindowsBasicLimitInformation(ctypes.Structure):
|
|
288
|
+
_fields_ = [
|
|
289
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
290
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
291
|
+
("LimitFlags", ctypes.c_uint32),
|
|
292
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
293
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
294
|
+
("ActiveProcessLimit", ctypes.c_uint32),
|
|
295
|
+
("Affinity", ctypes.c_size_t),
|
|
296
|
+
("PriorityClass", ctypes.c_uint32),
|
|
297
|
+
("SchedulingClass", ctypes.c_uint32),
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class _WindowsExtendedLimitInformation(ctypes.Structure):
|
|
302
|
+
_fields_ = [
|
|
303
|
+
("BasicLimitInformation", _WindowsBasicLimitInformation),
|
|
304
|
+
("IoInfo", _WindowsIoCounters),
|
|
305
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
306
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
307
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
308
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class _WindowsBasicAccountingInformation(ctypes.Structure):
|
|
313
|
+
_fields_ = [
|
|
314
|
+
("TotalUserTime", ctypes.c_int64),
|
|
315
|
+
("TotalKernelTime", ctypes.c_int64),
|
|
316
|
+
("ThisPeriodTotalUserTime", ctypes.c_int64),
|
|
317
|
+
("ThisPeriodTotalKernelTime", ctypes.c_int64),
|
|
318
|
+
("TotalPageFaultCount", ctypes.c_uint32),
|
|
319
|
+
("TotalProcesses", ctypes.c_uint32),
|
|
320
|
+
("ActiveProcesses", ctypes.c_uint32),
|
|
321
|
+
("TotalTerminatedProcesses", ctypes.c_uint32),
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class _WindowsKillOnCloseJob:
|
|
326
|
+
"""Best-effort Windows Job Object that kills descendants when closed."""
|
|
327
|
+
|
|
328
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
329
|
+
_JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800
|
|
330
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
|
|
331
|
+
_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1
|
|
332
|
+
|
|
333
|
+
def __init__(self) -> None:
|
|
334
|
+
self.handle: int | None = None
|
|
335
|
+
|
|
336
|
+
def assign(self, process: subprocess.Popen[object]) -> bool:
|
|
337
|
+
if os.name != "nt":
|
|
338
|
+
return False
|
|
339
|
+
kernel32 = ctypes.windll.kernel32
|
|
340
|
+
kernel32.CreateJobObjectW.restype = ctypes.c_void_p
|
|
341
|
+
handle = kernel32.CreateJobObjectW(None, None)
|
|
342
|
+
if not handle:
|
|
343
|
+
return False
|
|
344
|
+
info = _WindowsExtendedLimitInformation()
|
|
345
|
+
info.BasicLimitInformation.LimitFlags = (
|
|
346
|
+
self._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
347
|
+
| self._JOB_OBJECT_LIMIT_BREAKAWAY_OK
|
|
348
|
+
)
|
|
349
|
+
configured = kernel32.SetInformationJobObject(
|
|
350
|
+
ctypes.c_void_p(handle),
|
|
351
|
+
self._JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
|
|
352
|
+
ctypes.byref(info),
|
|
353
|
+
ctypes.sizeof(info),
|
|
354
|
+
)
|
|
355
|
+
process_handle = getattr(process, "_handle", None)
|
|
356
|
+
assigned = bool(
|
|
357
|
+
configured
|
|
358
|
+
and process_handle
|
|
359
|
+
and kernel32.AssignProcessToJobObject(
|
|
360
|
+
ctypes.c_void_p(handle),
|
|
361
|
+
ctypes.c_void_p(int(process_handle)),
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
if not assigned:
|
|
365
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
366
|
+
return False
|
|
367
|
+
self.handle = int(handle)
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
def close(self) -> None:
|
|
371
|
+
if self.handle is None:
|
|
372
|
+
return
|
|
373
|
+
ctypes.windll.kernel32.CloseHandle(ctypes.c_void_p(self.handle))
|
|
374
|
+
self.handle = None
|
|
375
|
+
|
|
376
|
+
def terminate_and_wait(self, timeout_seconds: float = 5.0) -> None:
|
|
377
|
+
if self.handle is None:
|
|
378
|
+
return
|
|
379
|
+
kernel32 = ctypes.windll.kernel32
|
|
380
|
+
handle = ctypes.c_void_p(self.handle)
|
|
381
|
+
kernel32.TerminateJobObject(handle, 1)
|
|
382
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
383
|
+
while time.monotonic() < deadline:
|
|
384
|
+
accounting = _WindowsBasicAccountingInformation()
|
|
385
|
+
queried = kernel32.QueryInformationJobObject(
|
|
386
|
+
handle,
|
|
387
|
+
self._JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS,
|
|
388
|
+
ctypes.byref(accounting),
|
|
389
|
+
ctypes.sizeof(accounting),
|
|
390
|
+
None,
|
|
391
|
+
)
|
|
392
|
+
if not queried or accounting.ActiveProcesses == 0:
|
|
393
|
+
break
|
|
394
|
+
time.sleep(0.02)
|
|
395
|
+
self.close()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class _WindowsProcessEntry(ctypes.Structure):
|
|
399
|
+
_fields_ = [
|
|
400
|
+
("dwSize", ctypes.c_uint32),
|
|
401
|
+
("cntUsage", ctypes.c_uint32),
|
|
402
|
+
("th32ProcessID", ctypes.c_uint32),
|
|
403
|
+
("th32DefaultHeapID", ctypes.c_size_t),
|
|
404
|
+
("th32ModuleID", ctypes.c_uint32),
|
|
405
|
+
("cntThreads", ctypes.c_uint32),
|
|
406
|
+
("th32ParentProcessID", ctypes.c_uint32),
|
|
407
|
+
("pcPriClassBase", ctypes.c_long),
|
|
408
|
+
("dwFlags", ctypes.c_uint32),
|
|
409
|
+
("szExeFile", ctypes.c_wchar * 260),
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _windows_process_parents() -> dict[int, int] | None:
|
|
414
|
+
if os.name != "nt":
|
|
415
|
+
return {}
|
|
416
|
+
kernel32 = ctypes.windll.kernel32
|
|
417
|
+
create_snapshot = kernel32.CreateToolhelp32Snapshot
|
|
418
|
+
create_snapshot.restype = ctypes.c_void_p
|
|
419
|
+
handle = create_snapshot(0x00000002, 0)
|
|
420
|
+
invalid_handle = ctypes.c_void_p(-1).value
|
|
421
|
+
if not handle or handle == invalid_handle:
|
|
422
|
+
return None
|
|
423
|
+
entry = _WindowsProcessEntry()
|
|
424
|
+
entry.dwSize = ctypes.sizeof(entry)
|
|
425
|
+
parents: dict[int, int] = {}
|
|
426
|
+
try:
|
|
427
|
+
if not kernel32.Process32FirstW(ctypes.c_void_p(handle), ctypes.byref(entry)):
|
|
428
|
+
return None
|
|
429
|
+
while True:
|
|
430
|
+
parents[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID)
|
|
431
|
+
if not kernel32.Process32NextW(
|
|
432
|
+
ctypes.c_void_p(handle),
|
|
433
|
+
ctypes.byref(entry),
|
|
434
|
+
):
|
|
435
|
+
break
|
|
436
|
+
return parents
|
|
437
|
+
finally:
|
|
438
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _update_windows_descendants(
|
|
442
|
+
root_pid: int,
|
|
443
|
+
known_descendants: set[int],
|
|
444
|
+
) -> bool:
|
|
445
|
+
parents = _windows_process_parents()
|
|
446
|
+
if parents is None:
|
|
447
|
+
return False
|
|
448
|
+
changed = True
|
|
449
|
+
while changed:
|
|
450
|
+
changed = False
|
|
451
|
+
for pid, parent_pid in parents.items():
|
|
452
|
+
if pid == root_pid or pid in known_descendants:
|
|
453
|
+
continue
|
|
454
|
+
if parent_pid == root_pid or parent_pid in known_descendants:
|
|
455
|
+
known_descendants.add(pid)
|
|
456
|
+
changed = True
|
|
457
|
+
return True
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _terminate_tracked_windows_descendants(pids: set[int]) -> None:
|
|
461
|
+
targeted: list[int] = []
|
|
462
|
+
kernel32 = ctypes.windll.kernel32
|
|
463
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
464
|
+
for pid in sorted(pids, reverse=True):
|
|
465
|
+
if not _pid_is_running(pid):
|
|
466
|
+
continue
|
|
467
|
+
targeted.append(pid)
|
|
468
|
+
handle = kernel32.OpenProcess(0x00100001, False, pid)
|
|
469
|
+
terminated = False
|
|
470
|
+
if handle:
|
|
471
|
+
try:
|
|
472
|
+
terminated = bool(
|
|
473
|
+
kernel32.TerminateProcess(ctypes.c_void_p(handle), 1)
|
|
474
|
+
)
|
|
475
|
+
if terminated:
|
|
476
|
+
kernel32.WaitForSingleObject(ctypes.c_void_p(handle), 5000)
|
|
477
|
+
finally:
|
|
478
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
479
|
+
if not terminated:
|
|
480
|
+
subprocess.run(
|
|
481
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
482
|
+
stdout=subprocess.DEVNULL,
|
|
483
|
+
stderr=subprocess.DEVNULL,
|
|
484
|
+
check=False,
|
|
485
|
+
timeout=10,
|
|
486
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
487
|
+
)
|
|
488
|
+
deadline = time.monotonic() + 5.0
|
|
489
|
+
while targeted and time.monotonic() < deadline:
|
|
490
|
+
targeted = [pid for pid in targeted if _pid_is_running(pid)]
|
|
491
|
+
if targeted:
|
|
492
|
+
time.sleep(0.05)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def detached_process_capability(cwd: Path) -> tuple[bool, str]:
|
|
496
|
+
"""Probe the nested Windows breakaway pattern used by service tests."""
|
|
497
|
+
|
|
498
|
+
if os.name != "nt":
|
|
499
|
+
return True, "not-windows"
|
|
500
|
+
flags = 0x00000008 | 0x00000200 | 0x08000000 | 0x01000000
|
|
501
|
+
with tempfile.TemporaryDirectory(prefix="harness-detached-probe-") as raw_tmp:
|
|
502
|
+
marker = Path(raw_tmp) / "nested-breakaway.ok"
|
|
503
|
+
probe_source = (
|
|
504
|
+
"import pathlib,subprocess,sys;"
|
|
505
|
+
"flags=0x00000008|0x00000200|0x08000000|0x01000000;"
|
|
506
|
+
"child=subprocess.Popen([sys.executable,'-c','pass'],"
|
|
507
|
+
"creationflags=flags,stdin=subprocess.DEVNULL,"
|
|
508
|
+
"stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);"
|
|
509
|
+
"code=child.wait(timeout=5);"
|
|
510
|
+
"pathlib.Path(sys.argv[1]).write_text(str(code),encoding='utf-8')"
|
|
511
|
+
)
|
|
512
|
+
try:
|
|
513
|
+
process = subprocess.Popen(
|
|
514
|
+
[sys.executable, "-c", probe_source, str(marker)],
|
|
515
|
+
cwd=str(Path(cwd).resolve()),
|
|
516
|
+
stdin=subprocess.DEVNULL,
|
|
517
|
+
stdout=subprocess.PIPE,
|
|
518
|
+
stderr=subprocess.STDOUT,
|
|
519
|
+
creationflags=flags,
|
|
520
|
+
text=True,
|
|
521
|
+
encoding="utf-8",
|
|
522
|
+
errors="replace",
|
|
523
|
+
)
|
|
524
|
+
except OSError as exc:
|
|
525
|
+
return False, str(exc)
|
|
526
|
+
try:
|
|
527
|
+
output, _ = process.communicate(timeout=8)
|
|
528
|
+
except subprocess.TimeoutExpired:
|
|
529
|
+
subprocess.run(
|
|
530
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
531
|
+
stdout=subprocess.DEVNULL,
|
|
532
|
+
stderr=subprocess.DEVNULL,
|
|
533
|
+
check=False,
|
|
534
|
+
timeout=10,
|
|
535
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
536
|
+
)
|
|
537
|
+
return False, "nested breakaway probe timed out"
|
|
538
|
+
if process.returncode == 0 and marker.is_file():
|
|
539
|
+
return True, "nested breakaway available"
|
|
540
|
+
detail_lines = [line.strip() for line in (output or "").splitlines() if line.strip()]
|
|
541
|
+
detail = detail_lines[-1] if detail_lines else ""
|
|
542
|
+
return False, detail[-500:] or f"probe exit={process.returncode}"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _terminate_process_tree(
|
|
546
|
+
process: subprocess.Popen[object],
|
|
547
|
+
*,
|
|
548
|
+
windows_job: _WindowsKillOnCloseJob | None,
|
|
549
|
+
) -> None:
|
|
550
|
+
if windows_job is not None and windows_job.handle is not None:
|
|
551
|
+
windows_job.terminate_and_wait()
|
|
552
|
+
elif os.name == "nt":
|
|
553
|
+
subprocess.run(
|
|
554
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
555
|
+
stdout=subprocess.DEVNULL,
|
|
556
|
+
stderr=subprocess.DEVNULL,
|
|
557
|
+
check=False,
|
|
558
|
+
timeout=10,
|
|
559
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
try:
|
|
563
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
564
|
+
except ProcessLookupError:
|
|
565
|
+
pass
|
|
566
|
+
try:
|
|
567
|
+
process.wait(timeout=2)
|
|
568
|
+
except subprocess.TimeoutExpired:
|
|
569
|
+
try:
|
|
570
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
571
|
+
except ProcessLookupError:
|
|
572
|
+
pass
|
|
573
|
+
if process.poll() is None:
|
|
574
|
+
process.kill()
|
|
575
|
+
try:
|
|
576
|
+
process.wait(timeout=5)
|
|
577
|
+
except subprocess.TimeoutExpired:
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _cleanup_descendants_after_success(
|
|
582
|
+
process: subprocess.Popen[object],
|
|
583
|
+
*,
|
|
584
|
+
windows_job: _WindowsKillOnCloseJob | None,
|
|
585
|
+
) -> None:
|
|
586
|
+
if windows_job is not None and windows_job.handle is not None:
|
|
587
|
+
windows_job.terminate_and_wait()
|
|
588
|
+
return
|
|
589
|
+
if os.name != "nt":
|
|
590
|
+
try:
|
|
591
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
592
|
+
except ProcessLookupError:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def run_managed_command(
|
|
597
|
+
argv: Sequence[str],
|
|
598
|
+
*,
|
|
599
|
+
cwd: Path,
|
|
600
|
+
timeout_seconds: float,
|
|
601
|
+
environ: Mapping[str, str] | None = None,
|
|
602
|
+
capture_output: bool = False,
|
|
603
|
+
allow_detached_processes: bool = False,
|
|
604
|
+
) -> CommandResult:
|
|
605
|
+
"""Run one command with a timeout and guaranteed process-tree cleanup."""
|
|
606
|
+
|
|
607
|
+
if not argv:
|
|
608
|
+
raise ValueError("managed command must not be empty")
|
|
609
|
+
child_env = os.environ.copy()
|
|
610
|
+
if environ:
|
|
611
|
+
child_env.update({str(key): str(value) for key, value in environ.items()})
|
|
612
|
+
child_env.setdefault("PYTHONUNBUFFERED", "1")
|
|
613
|
+
lower_process_priority()
|
|
614
|
+
|
|
615
|
+
popen_kwargs: dict[str, object] = {
|
|
616
|
+
"cwd": str(Path(cwd).resolve()),
|
|
617
|
+
"env": child_env,
|
|
618
|
+
"shell": False,
|
|
619
|
+
}
|
|
620
|
+
output_stream = tempfile.TemporaryFile(mode="w+b") if capture_output else None
|
|
621
|
+
if output_stream is not None:
|
|
622
|
+
popen_kwargs["stdout"] = output_stream
|
|
623
|
+
popen_kwargs["stderr"] = subprocess.STDOUT
|
|
624
|
+
if os.name == "nt":
|
|
625
|
+
popen_kwargs["creationflags"] = getattr(
|
|
626
|
+
subprocess,
|
|
627
|
+
"CREATE_NEW_PROCESS_GROUP",
|
|
628
|
+
0,
|
|
629
|
+
)
|
|
630
|
+
else:
|
|
631
|
+
popen_kwargs["start_new_session"] = True
|
|
632
|
+
|
|
633
|
+
started = time.monotonic()
|
|
634
|
+
try:
|
|
635
|
+
process = subprocess.Popen(list(argv), **popen_kwargs)
|
|
636
|
+
windows_job = (
|
|
637
|
+
_WindowsKillOnCloseJob()
|
|
638
|
+
if os.name == "nt" and not allow_detached_processes
|
|
639
|
+
else None
|
|
640
|
+
)
|
|
641
|
+
isolated = bool(windows_job and windows_job.assign(process))
|
|
642
|
+
known_windows_descendants: set[int] = set()
|
|
643
|
+
windows_snapshot_ok = True
|
|
644
|
+
if os.name != "nt":
|
|
645
|
+
isolated = True
|
|
646
|
+
timed_out = False
|
|
647
|
+
try:
|
|
648
|
+
deadline = time.monotonic() + max(0.01, float(timeout_seconds))
|
|
649
|
+
while True:
|
|
650
|
+
if os.name == "nt":
|
|
651
|
+
windows_snapshot_ok = (
|
|
652
|
+
_update_windows_descendants(
|
|
653
|
+
process.pid,
|
|
654
|
+
known_windows_descendants,
|
|
655
|
+
)
|
|
656
|
+
and windows_snapshot_ok
|
|
657
|
+
)
|
|
658
|
+
polled = process.poll()
|
|
659
|
+
if polled is not None:
|
|
660
|
+
returncode = polled
|
|
661
|
+
break
|
|
662
|
+
if time.monotonic() >= deadline:
|
|
663
|
+
timed_out = True
|
|
664
|
+
_terminate_process_tree(process, windows_job=windows_job)
|
|
665
|
+
returncode = 124
|
|
666
|
+
break
|
|
667
|
+
time.sleep(0.05)
|
|
668
|
+
except KeyboardInterrupt:
|
|
669
|
+
_terminate_process_tree(process, windows_job=windows_job)
|
|
670
|
+
if os.name == "nt":
|
|
671
|
+
_terminate_tracked_windows_descendants(known_windows_descendants)
|
|
672
|
+
raise
|
|
673
|
+
if not timed_out:
|
|
674
|
+
_cleanup_descendants_after_success(process, windows_job=windows_job)
|
|
675
|
+
if os.name == "nt":
|
|
676
|
+
_update_windows_descendants(process.pid, known_windows_descendants)
|
|
677
|
+
_terminate_tracked_windows_descendants(known_windows_descendants)
|
|
678
|
+
isolated = (
|
|
679
|
+
windows_snapshot_ok
|
|
680
|
+
if allow_detached_processes
|
|
681
|
+
else isolated and windows_snapshot_ok
|
|
682
|
+
)
|
|
683
|
+
duration = time.monotonic() - started
|
|
684
|
+
output_tail = ""
|
|
685
|
+
if output_stream is not None:
|
|
686
|
+
output_stream.flush()
|
|
687
|
+
output_stream.seek(0, os.SEEK_END)
|
|
688
|
+
size = output_stream.tell()
|
|
689
|
+
output_stream.seek(max(0, size - 65536))
|
|
690
|
+
output_tail = output_stream.read().decode("utf-8", errors="replace")
|
|
691
|
+
return CommandResult(
|
|
692
|
+
returncode=returncode,
|
|
693
|
+
timed_out=timed_out,
|
|
694
|
+
duration_seconds=duration,
|
|
695
|
+
process_tree_isolated=isolated,
|
|
696
|
+
output_tail=output_tail,
|
|
697
|
+
)
|
|
698
|
+
finally:
|
|
699
|
+
if output_stream is not None:
|
|
700
|
+
output_stream.close()
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _execution_environment(profile: str, max_workers: int) -> dict[str, str]:
|
|
704
|
+
return {
|
|
705
|
+
"HARNESS_TEST_PROFILE": profile,
|
|
706
|
+
"HARNESS_TEST_MAX_WORKERS": str(
|
|
707
|
+
bounded_worker_count(max_workers, os.environ)
|
|
708
|
+
),
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _print_plan(profile: str, plan: Sequence[TestModule]) -> None:
|
|
713
|
+
payload = {
|
|
714
|
+
"profile": profile,
|
|
715
|
+
"moduleCount": len(plan),
|
|
716
|
+
"modules": [item.name for item in plan],
|
|
717
|
+
"maxWorkers": bounded_worker_count(DEFAULT_MAX_WORKERS, os.environ),
|
|
718
|
+
"processTreeCleanup": True,
|
|
719
|
+
"singleInstance": True,
|
|
720
|
+
}
|
|
721
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _run_unittest(args: argparse.Namespace) -> int:
|
|
725
|
+
project = Path(args.project).resolve()
|
|
726
|
+
tests_dir = Path(args.tests_dir)
|
|
727
|
+
if not tests_dir.is_absolute():
|
|
728
|
+
tests_dir = project / tests_dir
|
|
729
|
+
plan = build_unittest_plan(
|
|
730
|
+
tests_dir,
|
|
731
|
+
args.profile,
|
|
732
|
+
resource_modules=args.resource_module,
|
|
733
|
+
)
|
|
734
|
+
if args.dry_run:
|
|
735
|
+
_print_plan(args.profile, plan)
|
|
736
|
+
return 0
|
|
737
|
+
if not resource_profile_allowed(
|
|
738
|
+
args.profile,
|
|
739
|
+
args.confirm_resource_intensive,
|
|
740
|
+
os.environ,
|
|
741
|
+
):
|
|
742
|
+
print(
|
|
743
|
+
"RESOURCE_CONFIRMATION_REQUIRED: "
|
|
744
|
+
f"profile={args.profile}; rerun with --confirm-resource-intensive",
|
|
745
|
+
file=sys.stderr,
|
|
746
|
+
)
|
|
747
|
+
return 2
|
|
748
|
+
if not plan:
|
|
749
|
+
print(f"NO_TEST_MODULES: profile={args.profile} testsDir={tests_dir}", file=sys.stderr)
|
|
750
|
+
return 2
|
|
751
|
+
|
|
752
|
+
environment = _execution_environment(args.profile, args.max_workers)
|
|
753
|
+
failures: list[str] = []
|
|
754
|
+
failed_modules: set[str] = set()
|
|
755
|
+
executed_count = 0
|
|
756
|
+
started = time.monotonic()
|
|
757
|
+
detached_service_modules = set(args.detached_service_module)
|
|
758
|
+
detached_capability_checked = False
|
|
759
|
+
try:
|
|
760
|
+
with TestRunLock(project):
|
|
761
|
+
for index, module in enumerate(plan, start=1):
|
|
762
|
+
if (
|
|
763
|
+
module.name in detached_service_modules
|
|
764
|
+
and not detached_capability_checked
|
|
765
|
+
):
|
|
766
|
+
available, detail = detached_process_capability(project)
|
|
767
|
+
detached_capability_checked = True
|
|
768
|
+
if not available:
|
|
769
|
+
print(
|
|
770
|
+
"DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
|
|
771
|
+
f"{detail}; rerun in an environment that permits "
|
|
772
|
+
"nested Windows breakaway",
|
|
773
|
+
file=sys.stderr,
|
|
774
|
+
)
|
|
775
|
+
return 5
|
|
776
|
+
print(
|
|
777
|
+
f"[harness-test] {index}/{len(plan)} {module.name} "
|
|
778
|
+
f"profile={args.profile} maxWorkers={environment['HARNESS_TEST_MAX_WORKERS']}",
|
|
779
|
+
flush=True,
|
|
780
|
+
)
|
|
781
|
+
command = [
|
|
782
|
+
sys.executable,
|
|
783
|
+
"-m",
|
|
784
|
+
"unittest",
|
|
785
|
+
"discover",
|
|
786
|
+
"-s",
|
|
787
|
+
str(tests_dir),
|
|
788
|
+
"-p",
|
|
789
|
+
module.name,
|
|
790
|
+
]
|
|
791
|
+
if args.verbosity == 0:
|
|
792
|
+
command.append("-q")
|
|
793
|
+
elif args.verbosity == 2:
|
|
794
|
+
command.append("-v")
|
|
795
|
+
result = run_managed_command(
|
|
796
|
+
command,
|
|
797
|
+
cwd=project,
|
|
798
|
+
timeout_seconds=args.timeout_seconds,
|
|
799
|
+
environ=environment,
|
|
800
|
+
capture_output=args.verbosity == 0,
|
|
801
|
+
allow_detached_processes=module.name in detached_service_modules,
|
|
802
|
+
)
|
|
803
|
+
executed_count += 1
|
|
804
|
+
module_failure_count = len(failures)
|
|
805
|
+
if result.timed_out:
|
|
806
|
+
failures.append(f"{module.name}:TIMEOUT")
|
|
807
|
+
failed_modules.add(module.name)
|
|
808
|
+
elif result.returncode != 0:
|
|
809
|
+
failures.append(f"{module.name}:EXIT_{result.returncode}")
|
|
810
|
+
failed_modules.add(module.name)
|
|
811
|
+
if not result.process_tree_isolated:
|
|
812
|
+
failures.append(f"{module.name}:PROCESS_TREE_ISOLATION_UNAVAILABLE")
|
|
813
|
+
failed_modules.add(module.name)
|
|
814
|
+
module_failed = len(failures) > module_failure_count
|
|
815
|
+
if module_failed and result.output_tail:
|
|
816
|
+
print(result.output_tail, file=sys.stderr, end="")
|
|
817
|
+
if module_failed and not args.keep_going:
|
|
818
|
+
break
|
|
819
|
+
except TestRunAlreadyActive as exc:
|
|
820
|
+
print(str(exc), file=sys.stderr)
|
|
821
|
+
return 3
|
|
822
|
+
|
|
823
|
+
duration = time.monotonic() - started
|
|
824
|
+
status = "PASS" if not failures else "FAIL"
|
|
825
|
+
print(
|
|
826
|
+
f"[harness-test] {status} profile={args.profile} "
|
|
827
|
+
f"modulesPassed={executed_count - len(failed_modules)} "
|
|
828
|
+
f"modulesExecuted={executed_count} modulesPlanned={len(plan)} "
|
|
829
|
+
f"durationSeconds={duration:.2f}",
|
|
830
|
+
flush=True,
|
|
831
|
+
)
|
|
832
|
+
if failures:
|
|
833
|
+
print("[harness-test] failures=" + ",".join(failures), file=sys.stderr)
|
|
834
|
+
return 1
|
|
835
|
+
return 0
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _run_exec(args: argparse.Namespace) -> int:
|
|
839
|
+
command = list(args.command)
|
|
840
|
+
if command and command[0] == "--":
|
|
841
|
+
command = command[1:]
|
|
842
|
+
if not command:
|
|
843
|
+
print("managed exec requires a command after --", file=sys.stderr)
|
|
844
|
+
return 2
|
|
845
|
+
if not resource_profile_allowed(
|
|
846
|
+
args.profile,
|
|
847
|
+
args.confirm_resource_intensive,
|
|
848
|
+
os.environ,
|
|
849
|
+
):
|
|
850
|
+
print(
|
|
851
|
+
"RESOURCE_CONFIRMATION_REQUIRED: "
|
|
852
|
+
f"profile={args.profile}; rerun with --confirm-resource-intensive",
|
|
853
|
+
file=sys.stderr,
|
|
854
|
+
)
|
|
855
|
+
return 2
|
|
856
|
+
project = Path(args.project).resolve()
|
|
857
|
+
environment = _execution_environment(args.profile, args.max_workers)
|
|
858
|
+
try:
|
|
859
|
+
with TestRunLock(project):
|
|
860
|
+
if args.allow_detached_processes:
|
|
861
|
+
available, detail = detached_process_capability(project)
|
|
862
|
+
if not available:
|
|
863
|
+
print(
|
|
864
|
+
"DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
|
|
865
|
+
f"{detail}; rerun in an environment that permits "
|
|
866
|
+
"nested Windows breakaway",
|
|
867
|
+
file=sys.stderr,
|
|
868
|
+
)
|
|
869
|
+
return 5
|
|
870
|
+
result = run_managed_command(
|
|
871
|
+
command,
|
|
872
|
+
cwd=project,
|
|
873
|
+
timeout_seconds=args.timeout_seconds,
|
|
874
|
+
environ=environment,
|
|
875
|
+
allow_detached_processes=args.allow_detached_processes,
|
|
876
|
+
)
|
|
877
|
+
except TestRunAlreadyActive as exc:
|
|
878
|
+
print(str(exc), file=sys.stderr)
|
|
879
|
+
return 3
|
|
880
|
+
if not result.process_tree_isolated:
|
|
881
|
+
print("PROCESS_TREE_ISOLATION_UNAVAILABLE", file=sys.stderr)
|
|
882
|
+
return 4
|
|
883
|
+
if result.timed_out:
|
|
884
|
+
print(
|
|
885
|
+
f"TEST_COMMAND_TIMEOUT: timeoutSeconds={args.timeout_seconds}",
|
|
886
|
+
file=sys.stderr,
|
|
887
|
+
)
|
|
888
|
+
return 124
|
|
889
|
+
return result.returncode
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
893
|
+
parser = argparse.ArgumentParser(
|
|
894
|
+
description="Run tests with bounded workers, locking, timeout, and cleanup."
|
|
895
|
+
)
|
|
896
|
+
subparsers = parser.add_subparsers(dest="command_name", required=True)
|
|
897
|
+
|
|
898
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
899
|
+
common.add_argument("--project", default=".")
|
|
900
|
+
common.add_argument(
|
|
901
|
+
"--profile",
|
|
902
|
+
choices=("safe", "system", "full"),
|
|
903
|
+
default="safe",
|
|
904
|
+
)
|
|
905
|
+
common.add_argument(
|
|
906
|
+
"--timeout-seconds",
|
|
907
|
+
type=float,
|
|
908
|
+
default=DEFAULT_TIMEOUT_SECONDS,
|
|
909
|
+
)
|
|
910
|
+
common.add_argument(
|
|
911
|
+
"--max-workers",
|
|
912
|
+
type=int,
|
|
913
|
+
default=DEFAULT_MAX_WORKERS,
|
|
914
|
+
)
|
|
915
|
+
common.add_argument("--confirm-resource-intensive", action="store_true")
|
|
916
|
+
|
|
917
|
+
unittest_parser = subparsers.add_parser(
|
|
918
|
+
"unittest",
|
|
919
|
+
parents=[common],
|
|
920
|
+
help="Run discovered Python unittest modules in isolated processes.",
|
|
921
|
+
)
|
|
922
|
+
unittest_parser.add_argument(
|
|
923
|
+
"--tests-dir",
|
|
924
|
+
default="harness/scripts/tests",
|
|
925
|
+
)
|
|
926
|
+
unittest_parser.add_argument(
|
|
927
|
+
"--resource-module",
|
|
928
|
+
action="append",
|
|
929
|
+
default=list(DEFAULT_RESOURCE_MODULES),
|
|
930
|
+
help="Module reserved for system/full profiles; may be repeated.",
|
|
931
|
+
)
|
|
932
|
+
unittest_parser.add_argument(
|
|
933
|
+
"--detached-service-module",
|
|
934
|
+
action="append",
|
|
935
|
+
default=list(DEFAULT_DETACHED_SERVICE_MODULES),
|
|
936
|
+
help="Module that intentionally tests detached services; may be repeated.",
|
|
937
|
+
)
|
|
938
|
+
unittest_parser.add_argument("--keep-going", action="store_true")
|
|
939
|
+
unittest_parser.add_argument("--dry-run", action="store_true")
|
|
940
|
+
unittest_parser.add_argument(
|
|
941
|
+
"--verbosity",
|
|
942
|
+
type=int,
|
|
943
|
+
choices=(0, 1, 2),
|
|
944
|
+
default=1,
|
|
945
|
+
)
|
|
946
|
+
unittest_parser.set_defaults(handler=_run_unittest)
|
|
947
|
+
|
|
948
|
+
exec_parser = subparsers.add_parser(
|
|
949
|
+
"exec",
|
|
950
|
+
parents=[common],
|
|
951
|
+
help="Run one arbitrary test command under the same safety controls.",
|
|
952
|
+
)
|
|
953
|
+
exec_parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
954
|
+
exec_parser.add_argument(
|
|
955
|
+
"--allow-detached-processes",
|
|
956
|
+
action="store_true",
|
|
957
|
+
help="Use exact Windows PID-lineage tracking instead of a Job Object.",
|
|
958
|
+
)
|
|
959
|
+
exec_parser.set_defaults(handler=_run_exec)
|
|
960
|
+
return parser
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
964
|
+
parser = build_parser()
|
|
965
|
+
args = parser.parse_args(argv)
|
|
966
|
+
if args.timeout_seconds <= 0:
|
|
967
|
+
parser.error("--timeout-seconds must be positive")
|
|
968
|
+
if args.max_workers <= 0:
|
|
969
|
+
parser.error("--max-workers must be positive")
|
|
970
|
+
return int(args.handler(args))
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
if __name__ == "__main__":
|
|
974
|
+
raise SystemExit(main())
|