@leejungkiin/awkit 1.7.0 → 1.7.4
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/bin/awk.js +576 -84
- package/core/CLAUDE.md +1 -1
- package/core/GEMINI.md +148 -167
- package/core/GEMINI.md.bak +149 -116
- package/core/skill-runtime-manifest.json +3 -0
- package/docs/Claude Fable 5.md +3826 -0
- package/docs/android_kotlin_system_instruction.md +210 -0
- package/docs/brainstorm_ponytail_integration.md +146 -0
- package/docs/brainstorm_smart_setup.md +113 -0
- package/docs/deep-research-report (1).md +293 -0
- package/docs/history/GEMINI.v1.md +135 -0
- package/docs/history/brainstorm_antigravity_unified_architecture.v1.md +105 -0
- package/docs/history/implementation_plan.v1.md +58 -0
- package/package.json +4 -1
- package/scripts/artifact-storage.js +130 -0
- package/scripts/automation-gate.js +40 -7
- package/scripts/claude-plan.js +76 -0
- package/scripts/dependency-manager.js +210 -0
- package/scripts/exec-rtk.js +11 -5
- package/scripts/i18n-helper.js +381 -0
- package/scripts/multi-model-pipeline.js +144 -0
- package/skill-packs/mobile-ios/pack.json +4 -2
- package/skill-packs/reverse-engineering/pack.json +1 -0
- package/skills/CATALOG.md +20 -0
- package/skills/GEMINI.md +9 -1
- package/skills/TRIGGER_INDEX.md +10 -0
- package/skills/ai-music/SKILL.md +275 -0
- package/skills/android-re-analyzer/SKILL.md +238 -0
- package/skills/android-re-analyzer/references/api-extraction-patterns.md +119 -0
- package/skills/android-re-analyzer/references/call-flow-analysis.md +176 -0
- package/skills/android-re-analyzer/references/fernflower-usage.md +115 -0
- package/skills/android-re-analyzer/references/jadx-usage.md +116 -0
- package/skills/android-re-analyzer/references/setup-guide.md +221 -0
- package/skills/android-re-analyzer/scripts/check-deps.sh +129 -0
- package/skills/android-re-analyzer/scripts/decompile.sh +375 -0
- package/skills/android-re-analyzer/scripts/find-api-calls.sh +118 -0
- package/skills/android-re-analyzer/scripts/install-dep.sh +448 -0
- package/skills/animal-island-ui-style/SKILL.md +1450 -0
- package/skills/app-store-review-agent/SKILL.md +164 -0
- package/skills/app-store-review-agent/references/guidelines/README.md +154 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/ai_apps.md +37 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/all_apps.md +50 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/crypto_finance.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/games.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/health_fitness.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/kids.md +27 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/macos.md +38 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/social_ugc.md +32 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/subscription_iap.md +34 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/vpn.md +18 -0
- package/skills/app-store-review-agent/references/rules/design/minimum_functionality.md +96 -0
- package/skills/app-store-review-agent/references/rules/design/sign_in_with_apple.md +54 -0
- package/skills/app-store-review-agent/references/rules/entitlements/unused_entitlements.md +83 -0
- package/skills/app-store-review-agent/references/rules/metadata/accurate_metadata.md +54 -0
- package/skills/app-store-review-agent/references/rules/metadata/apple_trademark.md +99 -0
- package/skills/app-store-review-agent/references/rules/metadata/china_storefront.md +72 -0
- package/skills/app-store-review-agent/references/rules/metadata/competitor_terms.md +56 -0
- package/skills/app-store-review-agent/references/rules/metadata/subscription_metadata.md +81 -0
- package/skills/app-store-review-agent/references/rules/privacy/privacy_manifest.md +84 -0
- package/skills/app-store-review-agent/references/rules/privacy/unnecessary_data.md +60 -0
- package/skills/app-store-review-agent/references/rules/subscription/misleading_pricing.md +63 -0
- package/skills/app-store-review-agent/references/rules/subscription/missing_tos_pp.md +54 -0
- package/skills/awf-ponytail/SKILL.md +91 -0
- package/skills/awf-ponytail-review/SKILL.md +67 -0
- package/skills/awf-session-restore/SKILL.md +3 -3
- package/skills/brainstorm-agent/SKILL.md +11 -2
- package/skills/brainstorm-agent/templates/brief-template.md +8 -0
- package/skills/claude-planner/SKILL.md +47 -0
- package/skills/code-review/SKILL.md +87 -0
- package/skills/expo-game-development/SKILL.md +163 -0
- package/skills/flutter/LICENSE.txt +202 -0
- package/skills/flutter/SKILL.md +127 -0
- package/skills/flutter-project-creater/LICENSE.txt +202 -0
- package/skills/flutter-project-creater/SKILL.md +106 -0
- package/skills/game-developer/SKILL.md +163 -0
- package/skills/game-developer/references/ecs-patterns.md +501 -0
- package/skills/game-developer/references/multiplayer-networking.md +475 -0
- package/skills/game-developer/references/performance-optimization.md +422 -0
- package/skills/game-developer/references/unity-patterns.md +271 -0
- package/skills/game-developer/references/unreal-cpp.md +352 -0
- package/skills/generate-gui-assets/SKILL.md +305 -0
- package/skills/generate-gui-assets/agents/openai.yaml +4 -0
- package/skills/generate-gui-assets/references/catalog-schema.md +58 -0
- package/skills/generate-gui-assets/references/extraction-techniques.md +21 -0
- package/skills/generate-gui-assets/references/prompt-patterns.md +58 -0
- package/skills/generate-gui-assets/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
- package/skills/generate-gui-assets/scripts/build_gui_contact_sheet.py +51 -0
- package/skills/generate-gui-assets/scripts/clean_chroma_edges.py +262 -0
- package/skills/generate-gui-assets/scripts/copy_approved_icons.py +64 -0
- package/skills/generate-gui-assets/scripts/prepare_gui_asset_run.py +91 -0
- package/skills/generate-gui-assets/scripts/suggest_grid_options.py +63 -0
- package/skills/generate-gui-assets/scripts/validate_gui_catalog.py +50 -0
- package/skills/godot-game-development/SKILL.md +142 -0
- package/skills/hatch-pet/LICENSE.txt +201 -0
- package/skills/hatch-pet/SKILL.md +420 -0
- package/skills/hatch-pet/agents/openai.yaml +4 -0
- package/skills/hatch-pet/references/animation-rows.md +29 -0
- package/skills/hatch-pet/references/codex-pet-contract.md +35 -0
- package/skills/hatch-pet/references/qa-rubric.md +60 -0
- package/skills/hatch-pet/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
- package/skills/hatch-pet/scripts/clean_chroma_edges.py +262 -0
- package/skills/hatch-pet/scripts/compose_atlas.py +150 -0
- package/skills/hatch-pet/scripts/derive_running_left_from_running_right.py +143 -0
- package/skills/hatch-pet/scripts/extract_strip_frames.py +323 -0
- package/skills/hatch-pet/scripts/finalize_pet_run.py +382 -0
- package/skills/hatch-pet/scripts/generate_pet_images.py +287 -0
- package/skills/hatch-pet/scripts/inspect_frames.py +246 -0
- package/skills/hatch-pet/scripts/make_contact_sheet.py +96 -0
- package/skills/hatch-pet/scripts/package_custom_pet.py +108 -0
- package/skills/hatch-pet/scripts/pet_job_status.py +117 -0
- package/skills/hatch-pet/scripts/prepare_pet_run.py +673 -0
- package/skills/hatch-pet/scripts/queue_pet_repairs.py +172 -0
- package/skills/hatch-pet/scripts/record_imagegen_result.py +250 -0
- package/skills/hatch-pet/scripts/render_animation_videos.py +134 -0
- package/skills/hatch-pet/scripts/render_animation_videos.sh +5 -0
- package/skills/hatch-pet/scripts/validate_atlas.py +139 -0
- package/skills/i18n-orchestrator/SKILL.md +37 -0
- package/skills/ios-simulator-skill/SKILL.md +390 -0
- package/skills/ios-simulator-skill/scripts/accessibility_audit.py +300 -0
- package/skills/ios-simulator-skill/scripts/app_launcher.py +326 -0
- package/skills/ios-simulator-skill/scripts/app_state_capture.py +400 -0
- package/skills/ios-simulator-skill/scripts/appearance.py +385 -0
- package/skills/ios-simulator-skill/scripts/build_and_test.py +348 -0
- package/skills/ios-simulator-skill/scripts/clipboard.py +103 -0
- package/skills/ios-simulator-skill/scripts/common/__init__.py +61 -0
- package/skills/ios-simulator-skill/scripts/common/cache_utils.py +289 -0
- package/skills/ios-simulator-skill/scripts/common/device_utils.py +462 -0
- package/skills/ios-simulator-skill/scripts/common/env_config.py +35 -0
- package/skills/ios-simulator-skill/scripts/common/hang_pipeline.py +862 -0
- package/skills/ios-simulator-skill/scripts/common/hang_sessions.py +490 -0
- package/skills/ios-simulator-skill/scripts/common/idb_utils.py +180 -0
- package/skills/ios-simulator-skill/scripts/common/screenshot_utils.py +338 -0
- package/skills/ios-simulator-skill/scripts/container.py +668 -0
- package/skills/ios-simulator-skill/scripts/gesture.py +394 -0
- package/skills/ios-simulator-skill/scripts/hang_watcher.py +1533 -0
- package/skills/ios-simulator-skill/scripts/keyboard.py +391 -0
- package/skills/ios-simulator-skill/scripts/localization_audit.py +483 -0
- package/skills/ios-simulator-skill/scripts/location.py +467 -0
- package/skills/ios-simulator-skill/scripts/log_monitor.py +493 -0
- package/skills/ios-simulator-skill/scripts/model_inspector.py +645 -0
- package/skills/ios-simulator-skill/scripts/navigator.py +461 -0
- package/skills/ios-simulator-skill/scripts/privacy_manager.py +310 -0
- package/skills/ios-simulator-skill/scripts/push_notification.py +240 -0
- package/skills/ios-simulator-skill/scripts/screen_mapper.py +296 -0
- package/skills/ios-simulator-skill/scripts/sim_health_check.sh +245 -0
- package/skills/ios-simulator-skill/scripts/sim_list.py +299 -0
- package/skills/ios-simulator-skill/scripts/simctl_boot.py +312 -0
- package/skills/ios-simulator-skill/scripts/simctl_create.py +316 -0
- package/skills/ios-simulator-skill/scripts/simctl_delete.py +357 -0
- package/skills/ios-simulator-skill/scripts/simctl_erase.py +351 -0
- package/skills/ios-simulator-skill/scripts/simctl_shutdown.py +290 -0
- package/skills/ios-simulator-skill/scripts/simulator_selector.py +375 -0
- package/skills/ios-simulator-skill/scripts/status_bar.py +250 -0
- package/skills/ios-simulator-skill/scripts/test_recorder.py +323 -0
- package/skills/ios-simulator-skill/scripts/visual_diff.py +235 -0
- package/skills/ios-simulator-skill/scripts/xcode/__init__.py +13 -0
- package/skills/ios-simulator-skill/scripts/xcode/builder.py +397 -0
- package/skills/ios-simulator-skill/scripts/xcode/cache.py +204 -0
- package/skills/ios-simulator-skill/scripts/xcode/config.py +178 -0
- package/skills/ios-simulator-skill/scripts/xcode/reporter.py +343 -0
- package/skills/ios-simulator-skill/scripts/xcode/xcresult.py +451 -0
- package/skills/ios-visual-qa-strategist/SKILL.md +111 -0
- package/skills/ios-visual-qa-strategist/agents/openai.yaml +4 -0
- package/skills/ios-visual-qa-strategist/references/ios-tool-selection.md +61 -0
- package/skills/ios-visual-qa-strategist/references/minimal-capture-policy.md +56 -0
- package/skills/ios-visual-qa-strategist/references/visual-reasoning-heuristics.md +53 -0
- package/skills/orchestrator/SKILL.md +0 -20
- package/skills/persistent-storage/SKILL.md +55 -0
- package/skills/short-maker/SKILL.md +23 -0
- package/skills/short-maker/scripts/effects.js +56 -0
- package/skills/short-maker/scripts/shortmaker-bridge.js +332 -0
- package/skills/short-maker/scripts/videomix.js +601 -0
- package/skills/short-maker/templates/hyperframes/cinematic-character.template.html +172 -0
- package/skills/short-maker/templates/hyperframes/index.template.html +194 -0
- package/skills/smali-to-kotlin/SKILL.md +128 -0
- package/skills/smali-to-kotlin/examples/getting-started/tech-stack.md +58 -0
- package/skills/smali-to-kotlin/examples/pipeline/data-ui-parity.md +118 -0
- package/skills/smali-to-kotlin/examples/pipeline/scanner-and-bootstrap.md +106 -0
- package/skills/smali-to-kotlin/library-patterns.md +189 -0
- package/skills/smali-to-kotlin/phase-0-discovery.md +128 -0
- package/skills/smali-to-kotlin/phase-1-architecture.md +166 -0
- package/skills/smali-to-kotlin/phase-2-blueprint-ui.md +347 -0
- package/skills/smali-to-kotlin/phase-2-blueprint.md +228 -0
- package/skills/smali-to-kotlin/phase-3-build.md +248 -0
- package/skills/smali-to-kotlin/phase-3-logic-build.md +268 -0
- package/skills/smali-to-kotlin/smali-reading-guide.md +310 -0
- package/skills/smali-to-kotlin/templates/app-map.md +101 -0
- package/skills/smali-to-kotlin/templates/architecture.md +142 -0
- package/skills/smali-to-kotlin/templates/blueprint.md +145 -0
- package/skills/spec-gate/SKILL.md +6 -2
- package/skills/symphony-enforcer/SKILL.md +8 -0
- package/skills/symphony-enforcer/examples/mindful-stop.md +2 -0
- package/skills/symphony-enforcer/examples/three-phase.md +16 -0
- package/skills/symphony-enforcer/examples/trigger-points.md +7 -1
- package/skills/unity-game-development/SKILL.md +231 -0
- package/skills/verification-gate/SKILL.md +4 -2
- package/skills/video-edit/SKILL.md +36 -0
- package/skills/video-edit/scripts/video_edit.py +324 -0
- package/templates/setup-mapping.json +48 -0
- package/templates/specs/design-template.md +161 -71
- package/templates/specs/requirements-template.md +65 -133
- package/templates/specs/task-spec-template.xml +3 -0
- package/workflows/_uncategorized/critic.md +40 -0
- package/workflows/_uncategorized/git-rebase-flow.md +81 -0
- package/workflows/_uncategorized/image-gen.md +118 -0
- package/workflows/_uncategorized/multi-model-pipeline.md +60 -0
- package/workflows/_uncategorized/pixel-gen.md +86 -0
- package/workflows/_uncategorized/pixel-setup.md +90 -0
- package/workflows/_uncategorized/ponytail-review.md +59 -0
- package/workflows/_uncategorized/reverse-android-build.md +222 -0
- package/workflows/_uncategorized/reverse-android-design.md +139 -0
- package/workflows/_uncategorized/reverse-android-discover.md +150 -0
- package/workflows/_uncategorized/reverse-android-scan.md +158 -0
- package/workflows/_uncategorized/reverse-android.md +143 -0
- package/workflows/_uncategorized/reverse-ios-build.md +240 -0
- package/workflows/_uncategorized/reverse-ios-design.md +112 -0
- package/workflows/_uncategorized/reverse-ios-discover.md +120 -0
- package/workflows/_uncategorized/reverse-ios-scan.md +155 -0
- package/workflows/_uncategorized/reverse-ios.md +152 -0
- package/workflows/_uncategorized/safety-router.md +34 -0
- package/workflows/_uncategorized/teach.md +89 -0
- package/workflows/_uncategorized/verify-ui.md +53 -0
- package/workflows/_uncategorized/visualize-screenshots.md +34 -0
- package/workflows/ads/ads-analyst.md +201 -0
- package/workflows/ads/ads-audit.md +106 -0
- package/workflows/ads/ads-optimize.md +97 -0
- package/workflows/ads/ads-targeting.md +241 -0
- package/workflows/ads/adsExpert.md +160 -0
- package/workflows/ads/smali-ads-config.md +400 -0
- package/workflows/ads/smali-ads-flow.md +331 -0
- package/workflows/ads/smali-ads-interstitial.md +377 -0
- package/workflows/ads/smali-ads-native.md +382 -0
- package/workflows/context/teach.md +89 -0
- package/workflows/gitnexus.md +8 -8
- package/workflows/lifecycle/brainstorm.md +43 -0
- package/workflows/lifecycle/code.md +5 -0
- package/workflows/lifecycle/init.md +23 -5
- package/workflows/lifecycle/multi-model-pipeline.md +60 -0
- package/workflows/quality/ponytail-review.md +59 -0
- package/workflows/roles/critic.md +40 -0
- package/workflows/roles/safety-router.md +34 -0
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
iOS Simulator Hang Watcher — featuring HangBuster session mode.
|
|
4
|
+
|
|
5
|
+
Two surfaces live in this file:
|
|
6
|
+
|
|
7
|
+
1. **HangWatcher** (legacy, --watch / --since) — passive os_log hang stream.
|
|
8
|
+
Backward-compatible with PR #75.
|
|
9
|
+
2. **HangBuster** (new, --start / --stop / --get-details / --list-sessions /
|
|
10
|
+
--clear-sessions / --diff) — agent-native session recorder. Detaches a
|
|
11
|
+
worker, normalises and thresholds events on the fly, clusters at stop time,
|
|
12
|
+
emits a token-tight summary with progressive drill paths.
|
|
13
|
+
|
|
14
|
+
The shared filter pipeline lives in ``common/hang_pipeline.py``; session
|
|
15
|
+
storage in ``common/hang_sessions.py``.
|
|
16
|
+
|
|
17
|
+
Environment variables (all read by ``common.env_config.env_int``):
|
|
18
|
+
|
|
19
|
+
- ``IOS_SIM_HANG_PREDICATE`` Override the default log predicate
|
|
20
|
+
- ``IOS_SIM_HANG_MIN_MS`` Min event duration kept (default 250)
|
|
21
|
+
- ``IOS_SIM_HANG_SESSION_TTL_HOURS`` Session prune age (default 24)
|
|
22
|
+
- ``IOS_SIM_HANG_DEFAULT_TOP_N`` Default top-N for ``--stop`` L1 (default 3)
|
|
23
|
+
- ``IOS_SIM_HANG_BUDGET_TOKENS`` Optional default for ``--budget-tokens``
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import contextlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import select
|
|
32
|
+
import signal
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
from datetime import datetime, timedelta
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
# Resolve imports whether run from repo root or scripts/ directory
|
|
40
|
+
_script_dir = str(Path(__file__).resolve().parent)
|
|
41
|
+
if _script_dir not in sys.path:
|
|
42
|
+
sys.path.insert(0, _script_dir)
|
|
43
|
+
|
|
44
|
+
from common.cache_utils import ProgressiveCache # noqa: E402
|
|
45
|
+
from common.device_utils import resolve_device_identifier # noqa: E402
|
|
46
|
+
from common.env_config import env_int # noqa: E402
|
|
47
|
+
from common.hang_pipeline import ( # noqa: E402
|
|
48
|
+
build_normalised_event,
|
|
49
|
+
compress_to_budget,
|
|
50
|
+
diff_sessions,
|
|
51
|
+
event_to_jsonl,
|
|
52
|
+
format_cluster_detail,
|
|
53
|
+
format_diff,
|
|
54
|
+
format_l0,
|
|
55
|
+
format_l1,
|
|
56
|
+
format_l2,
|
|
57
|
+
summary_to_json,
|
|
58
|
+
symbolicate_stack,
|
|
59
|
+
)
|
|
60
|
+
from common.hang_pipeline import ( # noqa: E402
|
|
61
|
+
extract_duration_ms as _pipeline_extract_duration_ms,
|
|
62
|
+
)
|
|
63
|
+
from common.hang_pipeline import ( # noqa: E402
|
|
64
|
+
is_hang_message as _pipeline_is_hang_message,
|
|
65
|
+
)
|
|
66
|
+
from common.hang_pipeline import ( # noqa: E402
|
|
67
|
+
parse_log_line as _pipeline_parse_log_line,
|
|
68
|
+
)
|
|
69
|
+
from common.hang_sessions import SessionStore # noqa: E402
|
|
70
|
+
|
|
71
|
+
# === CONSTANTS ===
|
|
72
|
+
|
|
73
|
+
# Default predicate: catches RunningBoard kills + SwiftUI/UIKit micro-hangs.
|
|
74
|
+
# Override with env var IOS_SIM_HANG_PREDICATE for custom tuning.
|
|
75
|
+
DEFAULT_HANG_PREDICATE = (
|
|
76
|
+
'(subsystem == "com.apple.runningboard") '
|
|
77
|
+
'OR (eventMessage CONTAINS "Hang detected") '
|
|
78
|
+
'OR ((eventMessage CONTAINS[c] "main thread") AND (eventMessage CONTAINS[c] "hang"))'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# How many times the worker re-spawns ``log stream`` after an EOF / subprocess
|
|
82
|
+
# death before giving up and marking the session crashed. Override via env var
|
|
83
|
+
# IOS_SIM_HANG_MAX_RESTARTS.
|
|
84
|
+
DEFAULT_MAX_STREAM_RESTARTS = 3
|
|
85
|
+
# Backoff between restart attempts. Short — log stream usually recovers fast.
|
|
86
|
+
RESTART_BACKOFF_SECONDS = 2.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _compute_start_timestamp(duration_str: str) -> str:
|
|
90
|
+
"""Parse duration string and return ISO-8601 start timestamp.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
duration_str: Duration like '30s', '5m', '1h'.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: If the format is unrecognised.
|
|
97
|
+
"""
|
|
98
|
+
match = re.match(r"(\d+)([smh])", duration_str.lower())
|
|
99
|
+
if not match:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Invalid duration format: {duration_str!r}. Use format like '30s', '5m', '1h'."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
value, unit = match.groups()
|
|
105
|
+
seconds = int(value) * {"s": 1, "m": 60, "h": 3600}[unit]
|
|
106
|
+
start = datetime.now() - timedelta(seconds=seconds)
|
|
107
|
+
return start.strftime("%Y-%m-%d %H:%M:%S")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# === HANG WATCHER ===
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class HangWatcher:
|
|
114
|
+
"""Watch for iOS simulator hang events via os_log stream."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, udid: str | None = None):
|
|
117
|
+
"""Initialize hang watcher.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
udid: Device UDID. Resolves to booted simulator if None.
|
|
121
|
+
"""
|
|
122
|
+
self.udid = udid
|
|
123
|
+
self.hang_events: list[dict] = []
|
|
124
|
+
self.interrupted = False
|
|
125
|
+
self._process: subprocess.Popen | None = None
|
|
126
|
+
self._cache = ProgressiveCache()
|
|
127
|
+
|
|
128
|
+
# === PUBLIC API ===
|
|
129
|
+
|
|
130
|
+
def watch(
|
|
131
|
+
self,
|
|
132
|
+
duration_seconds: int | None = None,
|
|
133
|
+
bundle_id: str | None = None,
|
|
134
|
+
predicate: str | None = None,
|
|
135
|
+
verbose: bool = False,
|
|
136
|
+
json_mode: bool = False,
|
|
137
|
+
) -> bool:
|
|
138
|
+
"""Stream hang events live from the simulator.
|
|
139
|
+
|
|
140
|
+
Runs `xcrun simctl spawn <udid> log stream --predicate <pred>` and
|
|
141
|
+
parses each line into a structured hang event. Stops after
|
|
142
|
+
duration_seconds or on Ctrl-C.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
duration_seconds: Stop after N seconds. None = run until Ctrl-C.
|
|
146
|
+
bundle_id: Filter events to a specific app bundle ID.
|
|
147
|
+
predicate: Custom log predicate. Falls back to env var then default.
|
|
148
|
+
verbose: Emit raw log lines alongside structured events.
|
|
149
|
+
json_mode: Emit JSON objects per line instead of formatted text.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
True if stream ran without fatal errors.
|
|
153
|
+
"""
|
|
154
|
+
resolved_udid = self._resolve_udid()
|
|
155
|
+
effective_predicate = _resolve_predicate(predicate)
|
|
156
|
+
cmd = self._build_stream_cmd(resolved_udid, effective_predicate)
|
|
157
|
+
|
|
158
|
+
if verbose or not json_mode:
|
|
159
|
+
print(
|
|
160
|
+
f"Watching for hangs on {resolved_udid}",
|
|
161
|
+
file=sys.stderr,
|
|
162
|
+
)
|
|
163
|
+
if bundle_id:
|
|
164
|
+
print(f"Post-parse filter: {bundle_id}", file=sys.stderr)
|
|
165
|
+
print(f"Predicate: {effective_predicate}", file=sys.stderr)
|
|
166
|
+
|
|
167
|
+
self._register_signal_handler()
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
self._process = subprocess.Popen(
|
|
171
|
+
cmd,
|
|
172
|
+
stdout=subprocess.PIPE,
|
|
173
|
+
stderr=subprocess.PIPE,
|
|
174
|
+
text=True,
|
|
175
|
+
bufsize=1,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
start_time = datetime.now()
|
|
179
|
+
|
|
180
|
+
for raw_line in iter(self._process.stdout.readline, ""):
|
|
181
|
+
if not raw_line:
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
line = raw_line.rstrip()
|
|
185
|
+
event = self._parse_line(line)
|
|
186
|
+
|
|
187
|
+
if event:
|
|
188
|
+
if bundle_id and not self._matches_bundle(event, bundle_id):
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
self.hang_events.append(event)
|
|
192
|
+
|
|
193
|
+
if json_mode:
|
|
194
|
+
print(json.dumps(event))
|
|
195
|
+
sys.stdout.flush()
|
|
196
|
+
else:
|
|
197
|
+
print(self._format_event(event))
|
|
198
|
+
if verbose:
|
|
199
|
+
print(f" raw: {line}")
|
|
200
|
+
|
|
201
|
+
elif verbose and line.strip():
|
|
202
|
+
print(f" [skip] {line}", file=sys.stderr)
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
duration_seconds
|
|
206
|
+
and (datetime.now() - start_time).total_seconds() >= duration_seconds
|
|
207
|
+
):
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
if self.interrupted:
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
# Terminate before wait — log stream never self-exits on duration elapsed.
|
|
214
|
+
if self._process and self._process.poll() is None:
|
|
215
|
+
self._process.terminate()
|
|
216
|
+
try:
|
|
217
|
+
self._process.wait(timeout=2)
|
|
218
|
+
except subprocess.TimeoutExpired:
|
|
219
|
+
self._process.kill()
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
except Exception as error:
|
|
223
|
+
print(f"Error streaming hang events: {error}", file=sys.stderr)
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
finally:
|
|
227
|
+
if self._process and self._process.poll() is None:
|
|
228
|
+
self._process.terminate()
|
|
229
|
+
|
|
230
|
+
def show_since(
|
|
231
|
+
self,
|
|
232
|
+
since_duration: str,
|
|
233
|
+
bundle_id: str | None = None,
|
|
234
|
+
predicate: str | None = None,
|
|
235
|
+
verbose: bool = False,
|
|
236
|
+
json_mode: bool = False,
|
|
237
|
+
) -> bool:
|
|
238
|
+
"""Show historical hang events using `log show`.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
since_duration: Duration string like "5m", "1h", "30s".
|
|
242
|
+
bundle_id: Filter to a specific app bundle ID.
|
|
243
|
+
predicate: Custom log predicate.
|
|
244
|
+
verbose: Include raw log lines.
|
|
245
|
+
json_mode: Emit JSON objects per line.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if command ran without fatal errors.
|
|
249
|
+
"""
|
|
250
|
+
resolved_udid = self._resolve_udid()
|
|
251
|
+
effective_predicate = _resolve_predicate(predicate)
|
|
252
|
+
start_timestamp = self._compute_start_timestamp(since_duration)
|
|
253
|
+
cmd = self._build_show_cmd(resolved_udid, effective_predicate, start_timestamp)
|
|
254
|
+
|
|
255
|
+
if verbose or not json_mode:
|
|
256
|
+
print(f"Showing hangs since {start_timestamp}", file=sys.stderr)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
cmd,
|
|
261
|
+
capture_output=True,
|
|
262
|
+
text=True,
|
|
263
|
+
timeout=60,
|
|
264
|
+
check=False,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
for raw_line in result.stdout.splitlines():
|
|
268
|
+
line = raw_line.rstrip()
|
|
269
|
+
event = self._parse_line(line)
|
|
270
|
+
|
|
271
|
+
if event:
|
|
272
|
+
if bundle_id and not self._matches_bundle(event, bundle_id):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
self.hang_events.append(event)
|
|
276
|
+
|
|
277
|
+
if json_mode:
|
|
278
|
+
print(json.dumps(event))
|
|
279
|
+
else:
|
|
280
|
+
print(self._format_event(event))
|
|
281
|
+
if verbose:
|
|
282
|
+
print(f" raw: {line}")
|
|
283
|
+
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
except subprocess.TimeoutExpired:
|
|
287
|
+
print("Error: log show timed out after 60s", file=sys.stderr)
|
|
288
|
+
return False
|
|
289
|
+
except Exception as error:
|
|
290
|
+
print(f"Error fetching historical hangs: {error}", file=sys.stderr)
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
def get_summary(self) -> str:
|
|
294
|
+
"""Return token-efficient summary of captured hang events."""
|
|
295
|
+
total = len(self.hang_events)
|
|
296
|
+
if total == 0:
|
|
297
|
+
return "No hang events detected."
|
|
298
|
+
|
|
299
|
+
processes = {}
|
|
300
|
+
for event in self.hang_events:
|
|
301
|
+
proc = event.get("process", "unknown")
|
|
302
|
+
processes[proc] = processes.get(proc, 0) + 1
|
|
303
|
+
|
|
304
|
+
top = sorted(processes.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
305
|
+
top_str = ", ".join(f"{p}({c})" for p, c in top)
|
|
306
|
+
return f"Hangs detected: {total} | Processes: {top_str}"
|
|
307
|
+
|
|
308
|
+
def get_json_output(self) -> dict:
|
|
309
|
+
"""Return full results as a JSON-serialisable dict."""
|
|
310
|
+
return {
|
|
311
|
+
"hang_events": self.hang_events,
|
|
312
|
+
"summary": {
|
|
313
|
+
"total_hangs": len(self.hang_events),
|
|
314
|
+
"processes": list({e.get("process") for e in self.hang_events}),
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
def save_to_cache(self) -> str:
|
|
319
|
+
"""Persist hang archive to progressive cache and return cache_id."""
|
|
320
|
+
return self._cache.save(self.get_json_output(), "hang-watcher")
|
|
321
|
+
|
|
322
|
+
# === PRIVATE HELPERS ===
|
|
323
|
+
|
|
324
|
+
def _resolve_udid(self) -> str:
|
|
325
|
+
"""Resolve UDID from stored value or booted device."""
|
|
326
|
+
identifier = self.udid or "booted"
|
|
327
|
+
try:
|
|
328
|
+
return resolve_device_identifier(identifier)
|
|
329
|
+
except RuntimeError as error:
|
|
330
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
331
|
+
sys.exit(1)
|
|
332
|
+
|
|
333
|
+
def _build_stream_cmd(self, udid: str, predicate: str) -> list[str]:
|
|
334
|
+
"""Build xcrun simctl spawn log stream command."""
|
|
335
|
+
return [
|
|
336
|
+
"xcrun",
|
|
337
|
+
"simctl",
|
|
338
|
+
"spawn",
|
|
339
|
+
udid,
|
|
340
|
+
"log",
|
|
341
|
+
"stream",
|
|
342
|
+
"--predicate",
|
|
343
|
+
predicate,
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
def _build_show_cmd(self, udid: str, predicate: str, start: str) -> list[str]:
|
|
347
|
+
"""Build xcrun simctl spawn log show command for historical queries."""
|
|
348
|
+
return [
|
|
349
|
+
"xcrun",
|
|
350
|
+
"simctl",
|
|
351
|
+
"spawn",
|
|
352
|
+
udid,
|
|
353
|
+
"log",
|
|
354
|
+
"show",
|
|
355
|
+
"--predicate",
|
|
356
|
+
predicate,
|
|
357
|
+
"--start",
|
|
358
|
+
start,
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
def _parse_line(self, line: str) -> dict | None:
|
|
362
|
+
"""Parse a log line into a hang event dict. Delegates to ``hang_pipeline``.
|
|
363
|
+
|
|
364
|
+
The legacy event dict carried ``duration_estimate_ms``; we map the
|
|
365
|
+
pipeline's ``duration_ms`` field back onto that name for backward compat.
|
|
366
|
+
"""
|
|
367
|
+
event = _pipeline_parse_log_line(line)
|
|
368
|
+
if event is None:
|
|
369
|
+
return None
|
|
370
|
+
if "duration_ms" in event:
|
|
371
|
+
event["duration_estimate_ms"] = event.pop("duration_ms")
|
|
372
|
+
return event
|
|
373
|
+
|
|
374
|
+
def _is_hang_message(self, message: str) -> bool:
|
|
375
|
+
"""Delegate to ``hang_pipeline.is_hang_message``."""
|
|
376
|
+
return _pipeline_is_hang_message(message)
|
|
377
|
+
|
|
378
|
+
def _extract_duration_ms(self, message: str) -> float | None:
|
|
379
|
+
"""Delegate to ``hang_pipeline.extract_duration_ms``."""
|
|
380
|
+
return _pipeline_extract_duration_ms(message)
|
|
381
|
+
|
|
382
|
+
def _matches_bundle(self, event: dict, bundle_id: str) -> bool:
|
|
383
|
+
"""Delegate to module-level ``matches_bundle`` (kept for legacy callers)."""
|
|
384
|
+
return matches_bundle(event, bundle_id)
|
|
385
|
+
|
|
386
|
+
def _format_event(self, event: dict) -> str:
|
|
387
|
+
"""Format a hang event for human-readable terminal output."""
|
|
388
|
+
duration_str = ""
|
|
389
|
+
if "duration_estimate_ms" in event:
|
|
390
|
+
ms = event["duration_estimate_ms"]
|
|
391
|
+
duration_str = f" [{ms / 1000:.1f}s]" if ms >= 1000 else f" [{ms:.0f}ms]"
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
f"HANG {event['timestamp']} | {event['process']} (PID {event['pid']})"
|
|
395
|
+
f"{duration_str} | {event['message'][:120]}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _compute_start_timestamp(self, duration_str: str) -> str:
|
|
399
|
+
"""Parse duration string and return ISO-8601 start timestamp."""
|
|
400
|
+
return _compute_start_timestamp(duration_str)
|
|
401
|
+
|
|
402
|
+
def _register_signal_handler(self):
|
|
403
|
+
"""Register SIGINT handler for graceful shutdown."""
|
|
404
|
+
|
|
405
|
+
def handle_sigint(sig, frame):
|
|
406
|
+
self.interrupted = True
|
|
407
|
+
if self._process:
|
|
408
|
+
self._process.terminate()
|
|
409
|
+
|
|
410
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# === HANGBUSTER (session mode) ===
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _resolve_predicate(predicate: str | None) -> str:
|
|
417
|
+
"""Resolution chain: CLI override → env var → default.
|
|
418
|
+
|
|
419
|
+
Bundle filtering is *not* applied here — see ``matches_bundle()``. The
|
|
420
|
+
default hang predicate matches events from RunningBoard, SpringBoard, and
|
|
421
|
+
the watchdog, none of which run inside the target app's process. ANDing a
|
|
422
|
+
``process == <app>`` clause silently drops the bulk of useful hang signal.
|
|
423
|
+
"""
|
|
424
|
+
return predicate or os.getenv("IOS_SIM_HANG_PREDICATE") or DEFAULT_HANG_PREDICATE
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def matches_bundle(event: dict, bundle_id: str) -> bool:
|
|
428
|
+
"""Check if a parsed log event's process name matches the bundle ID.
|
|
429
|
+
|
|
430
|
+
Applied post-parse so hang events from system processes (RunningBoard,
|
|
431
|
+
SpringBoard) still flow through the pipeline; ``--bundle-id`` narrows the
|
|
432
|
+
final output rather than the os_log predicate.
|
|
433
|
+
"""
|
|
434
|
+
app_name = bundle_id.rsplit(".", maxsplit=1)[-1].lower()
|
|
435
|
+
return app_name in event.get("process", "").lower()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class HangBuster:
|
|
439
|
+
"""Session-mode façade.
|
|
440
|
+
|
|
441
|
+
Methods route to ``SessionStore`` + filter pipeline. The worker subprocess
|
|
442
|
+
re-enters this class via ``run_worker()``.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
def __init__(self, store: SessionStore | None = None):
|
|
446
|
+
self.store = store or SessionStore()
|
|
447
|
+
|
|
448
|
+
# === PUBLIC API ===
|
|
449
|
+
|
|
450
|
+
def start(self, args: dict, udid: str | None) -> str:
|
|
451
|
+
"""Create session, detach worker, return session_id once worker registers."""
|
|
452
|
+
self.store.prune_expired()
|
|
453
|
+
aggregate_cap_mb = env_int("IOS_SIM_HANG_TOTAL_CAP_MB", 100)
|
|
454
|
+
if aggregate_cap_mb > 0:
|
|
455
|
+
self.store.prune_to_aggregate_cap(aggregate_cap_mb * 1024 * 1024)
|
|
456
|
+
resolved_udid = self._resolve_udid(udid)
|
|
457
|
+
meta = self.store.create({**args, "udid": resolved_udid})
|
|
458
|
+
cmd = [
|
|
459
|
+
sys.executable,
|
|
460
|
+
__file__,
|
|
461
|
+
"--worker-session-id",
|
|
462
|
+
meta.session_id,
|
|
463
|
+
]
|
|
464
|
+
# The detached worker survives parent exit. ``start_new_session=True``
|
|
465
|
+
# calls setsid() so the process group is independent of the controlling TTY.
|
|
466
|
+
subprocess.Popen(
|
|
467
|
+
cmd,
|
|
468
|
+
stdout=subprocess.DEVNULL,
|
|
469
|
+
stderr=subprocess.DEVNULL,
|
|
470
|
+
stdin=subprocess.DEVNULL,
|
|
471
|
+
start_new_session=True,
|
|
472
|
+
close_fds=True,
|
|
473
|
+
)
|
|
474
|
+
try:
|
|
475
|
+
self.store.wait_for_worker(meta.session_id, timeout_seconds=3.0)
|
|
476
|
+
except TimeoutError:
|
|
477
|
+
self.store.mark_crashed(meta.session_id)
|
|
478
|
+
raise RuntimeError(f"Worker did not register within 3s for {meta.session_id}") from None
|
|
479
|
+
return meta.session_id
|
|
480
|
+
|
|
481
|
+
def stop(
|
|
482
|
+
self,
|
|
483
|
+
session_id: str,
|
|
484
|
+
budget_tokens: int | None = None,
|
|
485
|
+
top_n: int | None = None,
|
|
486
|
+
terse: bool = False,
|
|
487
|
+
json_mode: bool = False,
|
|
488
|
+
) -> str:
|
|
489
|
+
"""Signal worker, drain, build summary, return formatted output."""
|
|
490
|
+
delivered = self.store.signal_worker(session_id, signal.SIGTERM)
|
|
491
|
+
if delivered:
|
|
492
|
+
# Give the worker up to 2s to flush + exit cleanly.
|
|
493
|
+
self._wait_for_worker_exit(session_id, timeout_seconds=2.0)
|
|
494
|
+
meta = self.store.load_meta(session_id)
|
|
495
|
+
line_counters = meta.extras.get("line_counters", {})
|
|
496
|
+
|
|
497
|
+
if meta.args.get("raw_capture"):
|
|
498
|
+
# Raw-capture sessions skip the clustering pipeline entirely.
|
|
499
|
+
return self._stop_raw_session(session_id, meta, line_counters, json_mode)
|
|
500
|
+
|
|
501
|
+
summary = self.store.build_summary(
|
|
502
|
+
session_id,
|
|
503
|
+
matched_lines=line_counters.get("matched", 0),
|
|
504
|
+
total_lines=line_counters.get("total", 0),
|
|
505
|
+
dropped_below_threshold=line_counters.get("dropped", 0),
|
|
506
|
+
)
|
|
507
|
+
# Apply default top_n at summary-write time — keeps ranked clusters bounded.
|
|
508
|
+
effective_top_n = top_n or env_int("IOS_SIM_HANG_DEFAULT_TOP_N", 3)
|
|
509
|
+
summary.clusters = summary.clusters[:effective_top_n]
|
|
510
|
+
self.store.stop(session_id, summary)
|
|
511
|
+
if json_mode:
|
|
512
|
+
return json.dumps(summary_to_json(summary), indent=2)
|
|
513
|
+
if terse:
|
|
514
|
+
return format_l0(summary)
|
|
515
|
+
budget = budget_tokens or env_int("IOS_SIM_HANG_BUDGET_TOKENS", 0) or None
|
|
516
|
+
return compress_to_budget(summary, max_tokens=budget, default_top_n=effective_top_n)
|
|
517
|
+
|
|
518
|
+
def _stop_raw_session(self, session_id: str, meta, line_counters: dict, json_mode: bool) -> str:
|
|
519
|
+
"""Finalise a raw-capture session: gzip raw.ndjson, write status, report.
|
|
520
|
+
|
|
521
|
+
No summary.json is written for raw sessions — clustering is not applied.
|
|
522
|
+
``--get-details`` redirects to the raw file.
|
|
523
|
+
"""
|
|
524
|
+
import gzip
|
|
525
|
+
import shutil
|
|
526
|
+
|
|
527
|
+
raw_path = self.store.raw_path(session_id)
|
|
528
|
+
gz_path = self.store.raw_path(session_id, gzipped=True)
|
|
529
|
+
raw_bytes = raw_path.stat().st_size if raw_path.exists() else 0
|
|
530
|
+
no_gzip = bool(meta.args.get("no_gzip"))
|
|
531
|
+
final_path = raw_path
|
|
532
|
+
if not no_gzip and raw_path.exists() and raw_bytes > 0:
|
|
533
|
+
with open(raw_path, "rb") as src, gzip.open(gz_path, "wb") as dst:
|
|
534
|
+
shutil.copyfileobj(src, dst)
|
|
535
|
+
raw_path.unlink()
|
|
536
|
+
final_path = gz_path
|
|
537
|
+
meta.extras["raw_gzipped"] = True
|
|
538
|
+
meta.extras["raw_bytes_compressed"] = gz_path.stat().st_size
|
|
539
|
+
|
|
540
|
+
meta.extras["raw_bytes"] = raw_bytes
|
|
541
|
+
# Mark stopped without going through build_summary (no summary.json for raw).
|
|
542
|
+
meta.status = "stopped"
|
|
543
|
+
from datetime import datetime as _dt
|
|
544
|
+
|
|
545
|
+
now = _dt.now()
|
|
546
|
+
meta.stopped_at = now.isoformat()
|
|
547
|
+
meta.stopped_at_ms = int(now.timestamp() * 1000)
|
|
548
|
+
self.store._write_meta(meta)
|
|
549
|
+
truncated = bool(meta.extras.get("truncated"))
|
|
550
|
+
if json_mode:
|
|
551
|
+
return json.dumps(
|
|
552
|
+
{
|
|
553
|
+
"session_id": session_id,
|
|
554
|
+
"mode": "raw",
|
|
555
|
+
"raw_path": str(final_path),
|
|
556
|
+
"raw_bytes": raw_bytes,
|
|
557
|
+
"raw_bytes_compressed": meta.extras.get("raw_bytes_compressed"),
|
|
558
|
+
"truncated": truncated,
|
|
559
|
+
"total_lines": line_counters.get("total", 0),
|
|
560
|
+
"stream_restarts": line_counters.get("stream_restarts", 0),
|
|
561
|
+
},
|
|
562
|
+
indent=2,
|
|
563
|
+
)
|
|
564
|
+
size_mb = raw_bytes / (1024 * 1024)
|
|
565
|
+
gz_mb = (meta.extras.get("raw_bytes_compressed") or 0) / (1024 * 1024)
|
|
566
|
+
trunc_str = " [TRUNCATED at size cap]" if truncated else ""
|
|
567
|
+
gz_str = f" → {gz_mb:.2f} MB gzipped" if gz_path.exists() else ""
|
|
568
|
+
explore_cmd = "zcat" if gz_path.exists() else "cat"
|
|
569
|
+
return (
|
|
570
|
+
f"Session {session_id}: raw mode, {line_counters.get('total', 0)} lines, "
|
|
571
|
+
f"{size_mb:.2f} MB{gz_str}{trunc_str}\n"
|
|
572
|
+
f"Explore: {explore_cmd} {final_path} | jq ..."
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def get_details(
|
|
576
|
+
self,
|
|
577
|
+
session_id: str,
|
|
578
|
+
cluster: int | None = None,
|
|
579
|
+
raw: bool = False,
|
|
580
|
+
resample: bool = False,
|
|
581
|
+
json_mode: bool = False,
|
|
582
|
+
symbolicate: bool = False,
|
|
583
|
+
app_binary: str | None = None,
|
|
584
|
+
dsym: str | None = None,
|
|
585
|
+
) -> str:
|
|
586
|
+
"""Drill into a stored session. ``cluster`` is 1-indexed for human use."""
|
|
587
|
+
try:
|
|
588
|
+
meta = self.store.load_meta(session_id)
|
|
589
|
+
except FileNotFoundError:
|
|
590
|
+
return f"Unknown session: {session_id}"
|
|
591
|
+
if meta.args.get("raw_capture"):
|
|
592
|
+
# Raw sessions have no clusters — point at the file.
|
|
593
|
+
gz_path = self.store.raw_path(session_id, gzipped=True)
|
|
594
|
+
ndjson_path = self.store.raw_path(session_id)
|
|
595
|
+
target = gz_path if gz_path.exists() else ndjson_path
|
|
596
|
+
cmd = "zcat" if gz_path.exists() else "cat"
|
|
597
|
+
return f"Raw session — explore with: {cmd} {target} | jq ..."
|
|
598
|
+
summary = self.store.load_summary(session_id)
|
|
599
|
+
if summary is None:
|
|
600
|
+
return f"No summary for {session_id}. Run --stop first."
|
|
601
|
+
if raw:
|
|
602
|
+
return self._dump_raw_events(session_id)
|
|
603
|
+
if cluster is not None:
|
|
604
|
+
index = cluster - 1
|
|
605
|
+
if index < 0 or index >= len(summary.clusters):
|
|
606
|
+
return f"Cluster {cluster} out of range (1..{len(summary.clusters)})"
|
|
607
|
+
target = summary.clusters[index]
|
|
608
|
+
events = [
|
|
609
|
+
e for e in self.store.read_events(session_id) if e.fingerprint == target.fingerprint
|
|
610
|
+
]
|
|
611
|
+
if resample:
|
|
612
|
+
fresh = _attempt_auto_sample(
|
|
613
|
+
meta.args.get("udid", ""), events[0].pid if events else 0
|
|
614
|
+
)
|
|
615
|
+
target.auto_samples = [fresh]
|
|
616
|
+
if symbolicate:
|
|
617
|
+
_apply_symbolication(target, app_binary, dsym)
|
|
618
|
+
if json_mode:
|
|
619
|
+
from common.hang_pipeline import cluster_to_json
|
|
620
|
+
|
|
621
|
+
return json.dumps(cluster_to_json(target), indent=2)
|
|
622
|
+
return format_cluster_detail(target, events)
|
|
623
|
+
if json_mode:
|
|
624
|
+
return json.dumps(summary_to_json(summary), indent=2)
|
|
625
|
+
return format_l2(summary)
|
|
626
|
+
|
|
627
|
+
def list_sessions(self, json_mode: bool = False) -> str:
|
|
628
|
+
metas = self.store.list_sessions()
|
|
629
|
+
if json_mode:
|
|
630
|
+
return json.dumps([m.to_json() for m in metas], indent=2)
|
|
631
|
+
if not metas:
|
|
632
|
+
return "No sessions stored."
|
|
633
|
+
lines = [f"Sessions: {len(metas)}"]
|
|
634
|
+
for meta in metas[:20]:
|
|
635
|
+
stopped = meta.stopped_at or "-"
|
|
636
|
+
counters = meta.extras.get("line_counters", {})
|
|
637
|
+
restarts = counters.get("stream_restarts", 0)
|
|
638
|
+
duration_s = (
|
|
639
|
+
(meta.stopped_at_ms - meta.started_at_ms) / 1000.0 if meta.stopped_at_ms else None
|
|
640
|
+
)
|
|
641
|
+
duration_str = f" capture={duration_s:.1f}s" if duration_s is not None else ""
|
|
642
|
+
restart_str = f" restarts={restarts}" if restarts else ""
|
|
643
|
+
raw_str = ""
|
|
644
|
+
if meta.args.get("raw_capture"):
|
|
645
|
+
size = meta.extras.get("raw_bytes_compressed") or meta.extras.get("raw_bytes") or 0
|
|
646
|
+
trunc = "T" if meta.extras.get("truncated") else "-"
|
|
647
|
+
raw_str = f" raw={size / 1024 / 1024:.2f}MB(trunc:{trunc})"
|
|
648
|
+
lines.append(
|
|
649
|
+
f" {meta.session_id} {meta.status:8s} started={meta.started_at} "
|
|
650
|
+
f"stopped={stopped}{duration_str}{restart_str}{raw_str}"
|
|
651
|
+
)
|
|
652
|
+
if len(metas) > 20:
|
|
653
|
+
lines.append(f" ... {len(metas) - 20} more")
|
|
654
|
+
return "\n".join(lines)
|
|
655
|
+
|
|
656
|
+
def clear_sessions(self, older_than: str | None = None, json_mode: bool = False) -> str:
|
|
657
|
+
deleted = self.store.clear(older_than=older_than)
|
|
658
|
+
if json_mode:
|
|
659
|
+
return json.dumps({"deleted": deleted, "older_than": older_than})
|
|
660
|
+
suffix = f" older than {older_than}" if older_than else ""
|
|
661
|
+
return f"Cleared {deleted} session(s){suffix}."
|
|
662
|
+
|
|
663
|
+
def diff(self, session_a: str, session_b: str, json_mode: bool = False) -> str:
|
|
664
|
+
summary_a = self.store.load_summary(session_a)
|
|
665
|
+
summary_b = self.store.load_summary(session_b)
|
|
666
|
+
if summary_a is None or summary_b is None:
|
|
667
|
+
missing = [s for s, x in [(session_a, summary_a), (session_b, summary_b)] if x is None]
|
|
668
|
+
return f"Missing summary.json for: {', '.join(missing)}"
|
|
669
|
+
result = diff_sessions(summary_a, summary_b)
|
|
670
|
+
if json_mode:
|
|
671
|
+
return json.dumps(result, indent=2)
|
|
672
|
+
return format_diff(result)
|
|
673
|
+
|
|
674
|
+
# === WORKER ===
|
|
675
|
+
|
|
676
|
+
def run_worker(self, session_id: str) -> int:
|
|
677
|
+
"""Long-running worker entrypoint. Returns exit code.
|
|
678
|
+
|
|
679
|
+
Layout: claim meta → resolve predicate → open events.jsonl line-buffered
|
|
680
|
+
→ for each restart attempt, spawn ``xcrun simctl spawn ... log stream``
|
|
681
|
+
and read lines until EOF or subprocess death. SIGTERM flushes and exits
|
|
682
|
+
cleanly. EOF / subprocess death triggers a bounded restart loop
|
|
683
|
+
(``IOS_SIM_HANG_MAX_RESTARTS``); on exhaustion the session is marked
|
|
684
|
+
``crashed`` rather than left in stale ``running`` state.
|
|
685
|
+
|
|
686
|
+
In ``--raw-capture`` mode the worker spawns ``log stream --style ndjson``
|
|
687
|
+
and dumps raw lines to ``raw.ndjson`` instead of parsing into the
|
|
688
|
+
clustering pipeline. Same restart/crash semantics; additional size-cap
|
|
689
|
+
bail when the raw file exceeds ``max_size_mb``.
|
|
690
|
+
"""
|
|
691
|
+
meta = self.store.claim_worker(session_id, pid=os.getpid())
|
|
692
|
+
args = meta.args
|
|
693
|
+
min_hang_ms = int(args.get("min_hang_ms", env_int("IOS_SIM_HANG_MIN_MS", 250)))
|
|
694
|
+
bundle_id = args.get("bundle_id")
|
|
695
|
+
predicate_override = args.get("predicate")
|
|
696
|
+
auto_sample = bool(args.get("auto_sample", False))
|
|
697
|
+
auto_spindump = bool(args.get("auto_spindump", False))
|
|
698
|
+
udid = args["udid"]
|
|
699
|
+
predicate = _resolve_predicate(predicate_override)
|
|
700
|
+
max_restarts = env_int("IOS_SIM_HANG_MAX_RESTARTS", DEFAULT_MAX_STREAM_RESTARTS)
|
|
701
|
+
raw_capture = bool(args.get("raw_capture", False))
|
|
702
|
+
max_size_bytes = int(args.get("max_size_mb", 10)) * 1024 * 1024 if raw_capture else 0
|
|
703
|
+
|
|
704
|
+
events_path = self.store.events_path(session_id)
|
|
705
|
+
counters = {"total": 0, "matched": 0, "dropped": 0, "stream_restarts": 0}
|
|
706
|
+
sampled_fingerprints: set[str] = set()
|
|
707
|
+
spindumped_fingerprints: set[str] = set()
|
|
708
|
+
stop_flag = {"value": False}
|
|
709
|
+
cap_state = {"hit": False} # set by raw reader when size cap exceeded
|
|
710
|
+
|
|
711
|
+
def _on_sigterm(_signum, _frame):
|
|
712
|
+
stop_flag["value"] = True
|
|
713
|
+
|
|
714
|
+
signal.signal(signal.SIGTERM, _on_sigterm)
|
|
715
|
+
signal.signal(signal.SIGINT, _on_sigterm)
|
|
716
|
+
|
|
717
|
+
cmd = ["xcrun", "simctl", "spawn", udid, "log", "stream", "--predicate", predicate]
|
|
718
|
+
if raw_capture:
|
|
719
|
+
cmd.extend(["--style", "ndjson"])
|
|
720
|
+
|
|
721
|
+
def _spawn_log_stream() -> subprocess.Popen:
|
|
722
|
+
return subprocess.Popen(
|
|
723
|
+
cmd,
|
|
724
|
+
stdout=subprocess.PIPE,
|
|
725
|
+
stderr=subprocess.DEVNULL,
|
|
726
|
+
stdin=subprocess.DEVNULL,
|
|
727
|
+
text=True,
|
|
728
|
+
bufsize=1,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
proc: subprocess.Popen | None = None
|
|
732
|
+
crashed = False
|
|
733
|
+
try:
|
|
734
|
+
with contextlib.ExitStack() as stack:
|
|
735
|
+
out_handle = stack.enter_context(open(events_path, "a", buffering=1))
|
|
736
|
+
raw_handle = (
|
|
737
|
+
stack.enter_context(open(self.store.raw_path(session_id), "a", buffering=1))
|
|
738
|
+
if raw_capture
|
|
739
|
+
else None
|
|
740
|
+
)
|
|
741
|
+
for attempt in range(max_restarts + 1):
|
|
742
|
+
if stop_flag["value"]:
|
|
743
|
+
break
|
|
744
|
+
if attempt > 0:
|
|
745
|
+
counters["stream_restarts"] = attempt
|
|
746
|
+
out_handle.write(
|
|
747
|
+
json.dumps(
|
|
748
|
+
{
|
|
749
|
+
"event": "stream_restart",
|
|
750
|
+
"attempt": attempt,
|
|
751
|
+
"at_ms": int(time.time() * 1000),
|
|
752
|
+
}
|
|
753
|
+
)
|
|
754
|
+
+ "\n"
|
|
755
|
+
)
|
|
756
|
+
out_handle.flush()
|
|
757
|
+
time.sleep(RESTART_BACKOFF_SECONDS)
|
|
758
|
+
if stop_flag["value"]:
|
|
759
|
+
break
|
|
760
|
+
try:
|
|
761
|
+
proc = _spawn_log_stream()
|
|
762
|
+
except FileNotFoundError:
|
|
763
|
+
crashed = True
|
|
764
|
+
break
|
|
765
|
+
|
|
766
|
+
if raw_capture:
|
|
767
|
+
exit_code = self._read_stream_into_raw(
|
|
768
|
+
proc=proc,
|
|
769
|
+
raw_handle=raw_handle,
|
|
770
|
+
stop_flag=stop_flag,
|
|
771
|
+
counters=counters,
|
|
772
|
+
max_size_bytes=max_size_bytes,
|
|
773
|
+
session_id=session_id,
|
|
774
|
+
cap_state=cap_state,
|
|
775
|
+
)
|
|
776
|
+
else:
|
|
777
|
+
exit_code = self._read_stream_into_events(
|
|
778
|
+
proc=proc,
|
|
779
|
+
out_handle=out_handle,
|
|
780
|
+
stop_flag=stop_flag,
|
|
781
|
+
counters=counters,
|
|
782
|
+
bundle_id=bundle_id,
|
|
783
|
+
min_hang_ms=min_hang_ms,
|
|
784
|
+
auto_sample=auto_sample,
|
|
785
|
+
auto_spindump=auto_spindump,
|
|
786
|
+
sampled_fingerprints=sampled_fingerprints,
|
|
787
|
+
spindumped_fingerprints=spindumped_fingerprints,
|
|
788
|
+
session_id=session_id,
|
|
789
|
+
session_start_ms=meta.started_at_ms,
|
|
790
|
+
udid=udid,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if cap_state["hit"]:
|
|
794
|
+
# Size-cap is a clean stop, not a crash. Record marker.
|
|
795
|
+
out_handle.write(
|
|
796
|
+
json.dumps(
|
|
797
|
+
{
|
|
798
|
+
"event": "size_cap_hit",
|
|
799
|
+
"bytes": self.store.raw_path(session_id).stat().st_size,
|
|
800
|
+
"at_ms": int(time.time() * 1000),
|
|
801
|
+
}
|
|
802
|
+
)
|
|
803
|
+
+ "\n"
|
|
804
|
+
)
|
|
805
|
+
out_handle.flush()
|
|
806
|
+
self._mark_truncated(session_id)
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
if stop_flag["value"]:
|
|
810
|
+
# Clean SIGTERM. Don't restart, don't mark crashed.
|
|
811
|
+
out_handle.write(
|
|
812
|
+
json.dumps({"event": "stream_ended", "at_ms": int(time.time() * 1000)})
|
|
813
|
+
+ "\n"
|
|
814
|
+
)
|
|
815
|
+
out_handle.flush()
|
|
816
|
+
break
|
|
817
|
+
|
|
818
|
+
# Subprocess died without a stop request. Record it.
|
|
819
|
+
out_handle.write(
|
|
820
|
+
json.dumps(
|
|
821
|
+
{
|
|
822
|
+
"event": "stream_died",
|
|
823
|
+
"exit_code": exit_code,
|
|
824
|
+
"attempt": attempt,
|
|
825
|
+
"at_ms": int(time.time() * 1000),
|
|
826
|
+
}
|
|
827
|
+
)
|
|
828
|
+
+ "\n"
|
|
829
|
+
)
|
|
830
|
+
out_handle.flush()
|
|
831
|
+
else:
|
|
832
|
+
# for/else: ran every restart without a clean break — exhausted.
|
|
833
|
+
crashed = True
|
|
834
|
+
|
|
835
|
+
with contextlib.suppress(OSError):
|
|
836
|
+
os.fsync(out_handle.fileno())
|
|
837
|
+
finally:
|
|
838
|
+
# raw_handle / out_handle are closed by ExitStack on with-block exit.
|
|
839
|
+
if proc is not None and proc.poll() is None:
|
|
840
|
+
proc.terminate()
|
|
841
|
+
try:
|
|
842
|
+
proc.wait(timeout=2)
|
|
843
|
+
except subprocess.TimeoutExpired:
|
|
844
|
+
proc.kill()
|
|
845
|
+
|
|
846
|
+
if crashed and not stop_flag["value"]:
|
|
847
|
+
self.store.mark_crashed(session_id)
|
|
848
|
+
# SessionStore.persist_worker_counters preserves terminal status
|
|
849
|
+
# (stopped from parent's stop(), or crashed above) and avoids
|
|
850
|
+
# clobbering it back to running.
|
|
851
|
+
self.store.persist_worker_counters(session_id, counters)
|
|
852
|
+
|
|
853
|
+
return 2 if crashed else 0
|
|
854
|
+
|
|
855
|
+
def _read_stream_into_raw(
|
|
856
|
+
self,
|
|
857
|
+
*,
|
|
858
|
+
proc: subprocess.Popen,
|
|
859
|
+
raw_handle,
|
|
860
|
+
stop_flag: dict,
|
|
861
|
+
counters: dict,
|
|
862
|
+
max_size_bytes: int,
|
|
863
|
+
session_id: str,
|
|
864
|
+
cap_state: dict,
|
|
865
|
+
) -> int | None:
|
|
866
|
+
"""Raw NDJSON capture: dump lines verbatim to raw.ndjson, no parsing.
|
|
867
|
+
|
|
868
|
+
Sets ``cap_state['hit'] = True`` and returns when the file exceeds
|
|
869
|
+
``max_size_bytes``. The caller treats that as a clean stop (no
|
|
870
|
+
restart, no crash) and writes a ``size_cap_hit`` marker.
|
|
871
|
+
"""
|
|
872
|
+
raw_path = self.store.raw_path(session_id)
|
|
873
|
+
bytes_written = raw_path.stat().st_size if raw_path.exists() else 0
|
|
874
|
+
last_fsync = time.time()
|
|
875
|
+
while not stop_flag["value"]:
|
|
876
|
+
if proc.stdout is None:
|
|
877
|
+
return proc.poll()
|
|
878
|
+
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
|
|
879
|
+
if not ready:
|
|
880
|
+
if time.time() - last_fsync > 1.0:
|
|
881
|
+
raw_handle.flush()
|
|
882
|
+
with contextlib.suppress(OSError):
|
|
883
|
+
os.fsync(raw_handle.fileno())
|
|
884
|
+
last_fsync = time.time()
|
|
885
|
+
exit_code = proc.poll()
|
|
886
|
+
if exit_code is not None:
|
|
887
|
+
return exit_code
|
|
888
|
+
continue
|
|
889
|
+
line = proc.stdout.readline()
|
|
890
|
+
if not line:
|
|
891
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
892
|
+
proc.wait(timeout=0.5)
|
|
893
|
+
return proc.poll()
|
|
894
|
+
counters["total"] += 1
|
|
895
|
+
# Drop non-JSON banners like `log stream`'s "Filtering the log data..." and
|
|
896
|
+
# any pwuid warnings. Raw consumers expect strict NDJSON.
|
|
897
|
+
stripped = line.lstrip()
|
|
898
|
+
if not stripped.startswith("{"):
|
|
899
|
+
continue
|
|
900
|
+
raw_handle.write(line if line.endswith("\n") else line + "\n")
|
|
901
|
+
bytes_written += len(line) + (0 if line.endswith("\n") else 1)
|
|
902
|
+
if max_size_bytes > 0 and bytes_written >= max_size_bytes:
|
|
903
|
+
cap_state["hit"] = True
|
|
904
|
+
raw_handle.flush()
|
|
905
|
+
with contextlib.suppress(OSError):
|
|
906
|
+
os.fsync(raw_handle.fileno())
|
|
907
|
+
return proc.poll()
|
|
908
|
+
return proc.poll()
|
|
909
|
+
|
|
910
|
+
def _mark_truncated(self, session_id: str) -> None:
|
|
911
|
+
"""Best-effort: set extras.truncated=True. Called when size cap hit."""
|
|
912
|
+
try:
|
|
913
|
+
meta = self.store.load_meta(session_id)
|
|
914
|
+
except FileNotFoundError:
|
|
915
|
+
return
|
|
916
|
+
meta.extras["truncated"] = True
|
|
917
|
+
# Write meta directly via the public store path; intentional: SessionStore
|
|
918
|
+
# exposes no extras setter and we want one round-trip not two.
|
|
919
|
+
self.store._write_meta(meta)
|
|
920
|
+
|
|
921
|
+
def _read_stream_into_events(
|
|
922
|
+
self,
|
|
923
|
+
*,
|
|
924
|
+
proc: subprocess.Popen,
|
|
925
|
+
out_handle,
|
|
926
|
+
stop_flag: dict,
|
|
927
|
+
counters: dict,
|
|
928
|
+
bundle_id: str | None,
|
|
929
|
+
min_hang_ms: int,
|
|
930
|
+
auto_sample: bool,
|
|
931
|
+
auto_spindump: bool,
|
|
932
|
+
sampled_fingerprints: set[str],
|
|
933
|
+
spindumped_fingerprints: set[str],
|
|
934
|
+
session_id: str,
|
|
935
|
+
session_start_ms: int,
|
|
936
|
+
udid: str,
|
|
937
|
+
) -> int | None:
|
|
938
|
+
"""Read lines until EOF / subprocess death / stop request.
|
|
939
|
+
|
|
940
|
+
Returns the subprocess exit code (None if still alive when stop_flag
|
|
941
|
+
was set, otherwise the recorded poll() value at exit time). Does not
|
|
942
|
+
emit ``stream_died`` / ``stream_ended`` events itself — the caller
|
|
943
|
+
decides which marker to write based on stop_flag.
|
|
944
|
+
"""
|
|
945
|
+
last_fsync = time.time()
|
|
946
|
+
while not stop_flag["value"]:
|
|
947
|
+
if proc.stdout is None:
|
|
948
|
+
return proc.poll()
|
|
949
|
+
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
|
|
950
|
+
if not ready:
|
|
951
|
+
if time.time() - last_fsync > 1.0:
|
|
952
|
+
out_handle.flush()
|
|
953
|
+
with contextlib.suppress(OSError):
|
|
954
|
+
os.fsync(out_handle.fileno())
|
|
955
|
+
last_fsync = time.time()
|
|
956
|
+
# If the subprocess silently died, poll() returns its code now.
|
|
957
|
+
# Otherwise keep waiting — quiet log streams are normal.
|
|
958
|
+
exit_code = proc.poll()
|
|
959
|
+
if exit_code is not None:
|
|
960
|
+
return exit_code
|
|
961
|
+
continue
|
|
962
|
+
line = proc.stdout.readline()
|
|
963
|
+
if not line:
|
|
964
|
+
# EOF — subprocess closed stdout. Wait briefly for poll() to
|
|
965
|
+
# settle so we report a meaningful exit code.
|
|
966
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
967
|
+
proc.wait(timeout=0.5)
|
|
968
|
+
return proc.poll()
|
|
969
|
+
counters["total"] += 1
|
|
970
|
+
raw_event = _pipeline_parse_log_line(line.rstrip())
|
|
971
|
+
if raw_event is None:
|
|
972
|
+
continue
|
|
973
|
+
if bundle_id and not matches_bundle(raw_event, bundle_id):
|
|
974
|
+
continue
|
|
975
|
+
counters["matched"] += 1
|
|
976
|
+
duration = raw_event.get("duration_ms")
|
|
977
|
+
if duration is None or duration < min_hang_ms:
|
|
978
|
+
counters["dropped"] += 1
|
|
979
|
+
continue
|
|
980
|
+
normalised = build_normalised_event(
|
|
981
|
+
raw_event,
|
|
982
|
+
session_start_ms=session_start_ms,
|
|
983
|
+
current_ms=int(time.time() * 1000),
|
|
984
|
+
)
|
|
985
|
+
if normalised is None:
|
|
986
|
+
continue
|
|
987
|
+
if auto_sample and normalised.fingerprint not in sampled_fingerprints:
|
|
988
|
+
sampled_fingerprints.add(normalised.fingerprint)
|
|
989
|
+
self._stash_auto_sample(
|
|
990
|
+
session_id, normalised, _attempt_auto_sample(udid, normalised.pid)
|
|
991
|
+
)
|
|
992
|
+
if auto_spindump and normalised.fingerprint not in spindumped_fingerprints:
|
|
993
|
+
spindumped_fingerprints.add(normalised.fingerprint)
|
|
994
|
+
self._stash_auto_sample(
|
|
995
|
+
session_id, normalised, _attempt_auto_spindump(udid, normalised.pid)
|
|
996
|
+
)
|
|
997
|
+
out_handle.write(event_to_jsonl(normalised) + "\n")
|
|
998
|
+
return proc.poll()
|
|
999
|
+
|
|
1000
|
+
# === PRIVATE ===
|
|
1001
|
+
|
|
1002
|
+
def _wait_for_worker_exit(self, session_id: str, timeout_seconds: float) -> None:
|
|
1003
|
+
meta = self.store.load_meta(session_id)
|
|
1004
|
+
if not meta.pid:
|
|
1005
|
+
return
|
|
1006
|
+
deadline = time.time() + timeout_seconds
|
|
1007
|
+
while time.time() < deadline:
|
|
1008
|
+
try:
|
|
1009
|
+
os.kill(meta.pid, 0)
|
|
1010
|
+
except ProcessLookupError:
|
|
1011
|
+
return
|
|
1012
|
+
time.sleep(0.05)
|
|
1013
|
+
|
|
1014
|
+
def _dump_raw_events(self, session_id: str) -> str:
|
|
1015
|
+
path = self.store.events_path(session_id)
|
|
1016
|
+
if not path.exists():
|
|
1017
|
+
return ""
|
|
1018
|
+
with open(path) as handle:
|
|
1019
|
+
return handle.read()
|
|
1020
|
+
|
|
1021
|
+
def _resolve_udid(self, udid: str | None) -> str:
|
|
1022
|
+
identifier = udid or "booted"
|
|
1023
|
+
try:
|
|
1024
|
+
return resolve_device_identifier(identifier)
|
|
1025
|
+
except RuntimeError as error:
|
|
1026
|
+
raise RuntimeError(str(error)) from error
|
|
1027
|
+
|
|
1028
|
+
def _stash_auto_sample(self, session_id: str, normalised, sample: dict) -> None:
|
|
1029
|
+
"""Record an auto-sample side-channel for later cluster annotation.
|
|
1030
|
+
|
|
1031
|
+
Delegates to SessionStore's append-only JSONL writer — concurrent stashes
|
|
1032
|
+
from a busy worker no longer race against each other.
|
|
1033
|
+
"""
|
|
1034
|
+
self.store.stash_auto_sample(session_id, normalised.fingerprint, sample)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
SAMPLE_DURATION_SECONDS = 1
|
|
1038
|
+
SAMPLE_TIMEOUT_SECONDS = 5
|
|
1039
|
+
SPINDUMP_DURATION_SECONDS = 1
|
|
1040
|
+
SPINDUMP_TIMEOUT_SECONDS = 10
|
|
1041
|
+
ATOS_TIMEOUT_SECONDS = 10
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _attempt_auto_sample(udid: str, pid: int) -> dict:
|
|
1045
|
+
"""Capture a main-thread stack via ``xcrun simctl spawn <udid> sample``.
|
|
1046
|
+
|
|
1047
|
+
Shells out to the in-simulator ``sample`` binary, which writes a textual
|
|
1048
|
+
main-thread profile to stdout. Short duration keeps the worker hot path
|
|
1049
|
+
responsive; fingerprint dedup at the caller means we sample at most once
|
|
1050
|
+
per unique hang pattern per session.
|
|
1051
|
+
"""
|
|
1052
|
+
if not udid:
|
|
1053
|
+
return {
|
|
1054
|
+
"kind": "simctl-sample",
|
|
1055
|
+
"stack": None,
|
|
1056
|
+
"captured_at_ms": int(time.time() * 1000),
|
|
1057
|
+
"symbolicated": False,
|
|
1058
|
+
"reason": "no udid available",
|
|
1059
|
+
}
|
|
1060
|
+
if not pid:
|
|
1061
|
+
return {
|
|
1062
|
+
"kind": "simctl-sample",
|
|
1063
|
+
"stack": None,
|
|
1064
|
+
"captured_at_ms": int(time.time() * 1000),
|
|
1065
|
+
"symbolicated": False,
|
|
1066
|
+
"reason": "no pid available",
|
|
1067
|
+
}
|
|
1068
|
+
cmd = [
|
|
1069
|
+
"xcrun",
|
|
1070
|
+
"simctl",
|
|
1071
|
+
"spawn",
|
|
1072
|
+
udid,
|
|
1073
|
+
"sample",
|
|
1074
|
+
str(pid),
|
|
1075
|
+
str(SAMPLE_DURATION_SECONDS),
|
|
1076
|
+
"-mayDie",
|
|
1077
|
+
"-file",
|
|
1078
|
+
"-",
|
|
1079
|
+
]
|
|
1080
|
+
captured_at_ms = int(time.time() * 1000)
|
|
1081
|
+
try:
|
|
1082
|
+
result = subprocess.run(
|
|
1083
|
+
cmd,
|
|
1084
|
+
capture_output=True,
|
|
1085
|
+
text=True,
|
|
1086
|
+
timeout=SAMPLE_TIMEOUT_SECONDS,
|
|
1087
|
+
check=False,
|
|
1088
|
+
)
|
|
1089
|
+
except subprocess.TimeoutExpired:
|
|
1090
|
+
return {
|
|
1091
|
+
"kind": "simctl-sample",
|
|
1092
|
+
"stack": None,
|
|
1093
|
+
"captured_at_ms": captured_at_ms,
|
|
1094
|
+
"symbolicated": False,
|
|
1095
|
+
"reason": "timeout",
|
|
1096
|
+
}
|
|
1097
|
+
except FileNotFoundError:
|
|
1098
|
+
return {
|
|
1099
|
+
"kind": "simctl-sample",
|
|
1100
|
+
"stack": None,
|
|
1101
|
+
"captured_at_ms": captured_at_ms,
|
|
1102
|
+
"symbolicated": False,
|
|
1103
|
+
"reason": "xcrun not found",
|
|
1104
|
+
}
|
|
1105
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1106
|
+
return {
|
|
1107
|
+
"kind": "simctl-sample",
|
|
1108
|
+
"stack": None,
|
|
1109
|
+
"captured_at_ms": captured_at_ms,
|
|
1110
|
+
"symbolicated": False,
|
|
1111
|
+
"reason": (result.stderr.strip() or f"sample exited {result.returncode}")[:200],
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
"kind": "simctl-sample",
|
|
1115
|
+
"stack": result.stdout,
|
|
1116
|
+
"captured_at_ms": captured_at_ms,
|
|
1117
|
+
"symbolicated": False,
|
|
1118
|
+
"reason": None,
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _attempt_auto_spindump(udid: str, pid: int) -> dict:
|
|
1123
|
+
"""Capture a hang report via ``xcrun simctl spawn <udid> spindump``.
|
|
1124
|
+
|
|
1125
|
+
``spindump`` is Apple's own hang-report tool — it produces a structured
|
|
1126
|
+
text report explicitly designed for the "what was main thread doing"
|
|
1127
|
+
question. Heavier than ``sample`` so we run a slightly longer timeout.
|
|
1128
|
+
"""
|
|
1129
|
+
captured_at_ms = int(time.time() * 1000)
|
|
1130
|
+
if not udid:
|
|
1131
|
+
return {
|
|
1132
|
+
"kind": "spindump",
|
|
1133
|
+
"stack": None,
|
|
1134
|
+
"captured_at_ms": captured_at_ms,
|
|
1135
|
+
"symbolicated": False,
|
|
1136
|
+
"reason": "no udid available",
|
|
1137
|
+
}
|
|
1138
|
+
if not pid:
|
|
1139
|
+
return {
|
|
1140
|
+
"kind": "spindump",
|
|
1141
|
+
"stack": None,
|
|
1142
|
+
"captured_at_ms": captured_at_ms,
|
|
1143
|
+
"symbolicated": False,
|
|
1144
|
+
"reason": "no pid available",
|
|
1145
|
+
}
|
|
1146
|
+
cmd = [
|
|
1147
|
+
"xcrun",
|
|
1148
|
+
"simctl",
|
|
1149
|
+
"spawn",
|
|
1150
|
+
udid,
|
|
1151
|
+
"spindump",
|
|
1152
|
+
str(pid),
|
|
1153
|
+
str(SPINDUMP_DURATION_SECONDS),
|
|
1154
|
+
"-file",
|
|
1155
|
+
"-",
|
|
1156
|
+
]
|
|
1157
|
+
try:
|
|
1158
|
+
result = subprocess.run(
|
|
1159
|
+
cmd,
|
|
1160
|
+
capture_output=True,
|
|
1161
|
+
text=True,
|
|
1162
|
+
timeout=SPINDUMP_TIMEOUT_SECONDS,
|
|
1163
|
+
check=False,
|
|
1164
|
+
)
|
|
1165
|
+
except subprocess.TimeoutExpired:
|
|
1166
|
+
return {
|
|
1167
|
+
"kind": "spindump",
|
|
1168
|
+
"stack": None,
|
|
1169
|
+
"captured_at_ms": captured_at_ms,
|
|
1170
|
+
"symbolicated": False,
|
|
1171
|
+
"reason": "timeout",
|
|
1172
|
+
}
|
|
1173
|
+
except FileNotFoundError:
|
|
1174
|
+
return {
|
|
1175
|
+
"kind": "spindump",
|
|
1176
|
+
"stack": None,
|
|
1177
|
+
"captured_at_ms": captured_at_ms,
|
|
1178
|
+
"symbolicated": False,
|
|
1179
|
+
"reason": "xcrun not found",
|
|
1180
|
+
}
|
|
1181
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1182
|
+
return {
|
|
1183
|
+
"kind": "spindump",
|
|
1184
|
+
"stack": None,
|
|
1185
|
+
"captured_at_ms": captured_at_ms,
|
|
1186
|
+
"symbolicated": False,
|
|
1187
|
+
"reason": (result.stderr.strip() or f"spindump exited {result.returncode}")[:200],
|
|
1188
|
+
}
|
|
1189
|
+
return {
|
|
1190
|
+
"kind": "spindump",
|
|
1191
|
+
"stack": result.stdout,
|
|
1192
|
+
"captured_at_ms": captured_at_ms,
|
|
1193
|
+
"symbolicated": False,
|
|
1194
|
+
"reason": None,
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _apply_symbolication(cluster, app_binary: str | None, dsym: str | None) -> None:
|
|
1199
|
+
"""In-place: rewrite each auto-sample's stack via atos using the chosen target.
|
|
1200
|
+
|
|
1201
|
+
No-ops if no target path is resolvable or atos returns nothing — failures
|
|
1202
|
+
must never strip the existing (unsymbolicated) stack, only enhance it.
|
|
1203
|
+
"""
|
|
1204
|
+
target = _resolve_symbolication_target(app_binary, dsym)
|
|
1205
|
+
if not target:
|
|
1206
|
+
return
|
|
1207
|
+
samples = cluster.auto_samples or ([cluster.auto_sample] if cluster.auto_sample else [])
|
|
1208
|
+
for sample in samples:
|
|
1209
|
+
if not sample or not sample.get("stack"):
|
|
1210
|
+
continue
|
|
1211
|
+
original = sample["stack"]
|
|
1212
|
+
rewritten = symbolicate_stack(original, lambda addrs: _run_atos(target, addrs))
|
|
1213
|
+
if rewritten != original:
|
|
1214
|
+
sample["stack"] = rewritten
|
|
1215
|
+
sample["symbolicated"] = True
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _run_atos(binary_path: str, addresses: list[str]) -> dict[str, str]:
|
|
1219
|
+
"""Resolve a batch of runtime addresses via ``xcrun atos -o <path>``.
|
|
1220
|
+
|
|
1221
|
+
Returns ``{addr: resolved_text}`` for every input address; addresses atos
|
|
1222
|
+
couldn't resolve come back as themselves (atos echoes the input). Failures
|
|
1223
|
+
return an empty dict so callers can fall through cleanly.
|
|
1224
|
+
"""
|
|
1225
|
+
if not binary_path or not addresses:
|
|
1226
|
+
return {}
|
|
1227
|
+
cmd = ["xcrun", "atos", "-o", binary_path, *addresses]
|
|
1228
|
+
try:
|
|
1229
|
+
result = subprocess.run(
|
|
1230
|
+
cmd,
|
|
1231
|
+
capture_output=True,
|
|
1232
|
+
text=True,
|
|
1233
|
+
timeout=ATOS_TIMEOUT_SECONDS,
|
|
1234
|
+
check=False,
|
|
1235
|
+
)
|
|
1236
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
1237
|
+
return {}
|
|
1238
|
+
if result.returncode != 0:
|
|
1239
|
+
return {}
|
|
1240
|
+
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
1241
|
+
# atos prints one resolved line per input address, in input order.
|
|
1242
|
+
return dict(zip(addresses, lines, strict=False))
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _resolve_symbolication_target(app_binary: str | None, dsym: str | None) -> str | None:
|
|
1246
|
+
"""Pick the path atos should resolve against. dSYM wins when both set."""
|
|
1247
|
+
explicit = dsym or app_binary
|
|
1248
|
+
if explicit:
|
|
1249
|
+
return explicit
|
|
1250
|
+
env_dsym = os.environ.get("IOS_SIM_HANG_DSYM", "").strip()
|
|
1251
|
+
if env_dsym:
|
|
1252
|
+
return env_dsym
|
|
1253
|
+
env_binary = os.environ.get("IOS_SIM_HANG_APP_BINARY", "").strip()
|
|
1254
|
+
return env_binary or None
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
# === CLI ===
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _add_legacy_args(parser: argparse.ArgumentParser) -> argparse._MutuallyExclusiveGroup:
|
|
1261
|
+
"""Add the v1 --watch / --since flags plus the new HangBuster subcommands.
|
|
1262
|
+
|
|
1263
|
+
All modes share the same parser so users can keep using v1 invocations
|
|
1264
|
+
unchanged. Subcommands are mutually exclusive (mode_group).
|
|
1265
|
+
"""
|
|
1266
|
+
mode_group = parser.add_mutually_exclusive_group(required=True)
|
|
1267
|
+
mode_group.add_argument(
|
|
1268
|
+
"--watch", action="store_true", help="Legacy live stream (until --duration / Ctrl-C)"
|
|
1269
|
+
)
|
|
1270
|
+
mode_group.add_argument(
|
|
1271
|
+
"--since",
|
|
1272
|
+
metavar="DURATION",
|
|
1273
|
+
help="Legacy historical query (e.g. 5m, 1h, 30s)",
|
|
1274
|
+
)
|
|
1275
|
+
mode_group.add_argument(
|
|
1276
|
+
"--start",
|
|
1277
|
+
action="store_true",
|
|
1278
|
+
help="Start a HangBuster session (detached worker, returns session ID)",
|
|
1279
|
+
)
|
|
1280
|
+
mode_group.add_argument(
|
|
1281
|
+
"--stop", metavar="SESSION_ID", help="Stop a session and emit the summary"
|
|
1282
|
+
)
|
|
1283
|
+
mode_group.add_argument(
|
|
1284
|
+
"--get-details",
|
|
1285
|
+
metavar="SESSION_ID",
|
|
1286
|
+
help="Drill into a stored session (combine with --cluster N or --raw)",
|
|
1287
|
+
)
|
|
1288
|
+
mode_group.add_argument(
|
|
1289
|
+
"--list-sessions", action="store_true", help="List stored HangBuster sessions"
|
|
1290
|
+
)
|
|
1291
|
+
mode_group.add_argument("--clear-sessions", action="store_true", help="Delete stored sessions")
|
|
1292
|
+
mode_group.add_argument(
|
|
1293
|
+
"--diff", nargs=2, metavar=("SESSION_A", "SESSION_B"), help="Compare two sessions"
|
|
1294
|
+
)
|
|
1295
|
+
# Internal worker entry — hidden from --help. Detached child re-enters this script with it.
|
|
1296
|
+
mode_group.add_argument("--worker-session-id", metavar="ID", help=argparse.SUPPRESS)
|
|
1297
|
+
return mode_group
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def main():
|
|
1301
|
+
"""Main entry point — supports v1 + HangBuster modes from one parser."""
|
|
1302
|
+
parser = argparse.ArgumentParser(
|
|
1303
|
+
description=(
|
|
1304
|
+
"Watch for iOS simulator hang events via os_log. "
|
|
1305
|
+
"Use --watch/--since for the v1 stream, or --start/--stop for HangBuster session mode."
|
|
1306
|
+
),
|
|
1307
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1308
|
+
epilog="""
|
|
1309
|
+
Examples:
|
|
1310
|
+
# HangBuster session mode (agent-friendly):
|
|
1311
|
+
SID=$(python scripts/hang_watcher.py --start --min-hang-ms 200)
|
|
1312
|
+
# ... interact with the simulator ...
|
|
1313
|
+
python scripts/hang_watcher.py --stop $SID
|
|
1314
|
+
python scripts/hang_watcher.py --get-details $SID --cluster 1
|
|
1315
|
+
python scripts/hang_watcher.py --list-sessions
|
|
1316
|
+
python scripts/hang_watcher.py --diff $SID_A $SID_B
|
|
1317
|
+
python scripts/hang_watcher.py --clear-sessions --older-than 24h
|
|
1318
|
+
|
|
1319
|
+
# Legacy:
|
|
1320
|
+
python scripts/hang_watcher.py --watch --duration 60
|
|
1321
|
+
python scripts/hang_watcher.py --since 5m --json
|
|
1322
|
+
|
|
1323
|
+
Environment variables:
|
|
1324
|
+
IOS_SIM_HANG_PREDICATE Override the default log predicate
|
|
1325
|
+
IOS_SIM_HANG_MIN_MS Min event duration kept (default 250)
|
|
1326
|
+
IOS_SIM_HANG_SESSION_TTL_HOURS Session prune age (default 24)
|
|
1327
|
+
IOS_SIM_HANG_DEFAULT_TOP_N Default top-N for --stop (default 3)
|
|
1328
|
+
IOS_SIM_HANG_BUDGET_TOKENS Default token budget for --stop
|
|
1329
|
+
""",
|
|
1330
|
+
)
|
|
1331
|
+
_add_legacy_args(parser)
|
|
1332
|
+
|
|
1333
|
+
# Filters / target
|
|
1334
|
+
parser.add_argument(
|
|
1335
|
+
"--bundle-id",
|
|
1336
|
+
help=(
|
|
1337
|
+
"Post-parse filter: drop events whose process name does not contain the "
|
|
1338
|
+
"app suffix from this bundle ID. Hang capture itself stays simulator-global "
|
|
1339
|
+
"(RunningBoard/SpringBoard events are kept)."
|
|
1340
|
+
),
|
|
1341
|
+
)
|
|
1342
|
+
parser.add_argument("--predicate", help="Override the default os_log predicate")
|
|
1343
|
+
parser.add_argument("--udid", help="Device UDID (uses booted simulator if omitted)")
|
|
1344
|
+
|
|
1345
|
+
# Legacy-only
|
|
1346
|
+
parser.add_argument(
|
|
1347
|
+
"--duration", type=int, metavar="SECONDS", help="Stop after N seconds (--watch only)"
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
# HangBuster knobs
|
|
1351
|
+
parser.add_argument(
|
|
1352
|
+
"--min-hang-ms",
|
|
1353
|
+
type=int,
|
|
1354
|
+
help="Drop events below this duration (default 250 / env IOS_SIM_HANG_MIN_MS)",
|
|
1355
|
+
)
|
|
1356
|
+
parser.add_argument(
|
|
1357
|
+
"--auto-sample",
|
|
1358
|
+
action="store_true",
|
|
1359
|
+
help="On hang, capture a main-thread stack via `xcrun simctl spawn <udid> sample`",
|
|
1360
|
+
)
|
|
1361
|
+
parser.add_argument(
|
|
1362
|
+
"--auto-spindump",
|
|
1363
|
+
action="store_true",
|
|
1364
|
+
help="On hang, capture a spindump report via `xcrun simctl spawn <udid> spindump`",
|
|
1365
|
+
)
|
|
1366
|
+
parser.add_argument("--top", type=int, dest="top_n", help="Top-N clusters to retain in summary")
|
|
1367
|
+
parser.add_argument(
|
|
1368
|
+
"--all", action="store_true", dest="all_clusters", help="Keep all clusters (no top-N cap)"
|
|
1369
|
+
)
|
|
1370
|
+
parser.add_argument(
|
|
1371
|
+
"--budget-tokens", type=int, help="Max tokens for --stop output (picks L0/L1/L2)"
|
|
1372
|
+
)
|
|
1373
|
+
parser.add_argument(
|
|
1374
|
+
"--cluster", type=int, help="Cluster index (1-based) for --get-details drill"
|
|
1375
|
+
)
|
|
1376
|
+
parser.add_argument(
|
|
1377
|
+
"--resample", action="store_true", help="With --get-details: force a fresh auto-sample"
|
|
1378
|
+
)
|
|
1379
|
+
parser.add_argument("--raw", action="store_true", help="With --get-details: dump events.jsonl")
|
|
1380
|
+
parser.add_argument(
|
|
1381
|
+
"--symbolicate",
|
|
1382
|
+
action="store_true",
|
|
1383
|
+
help="With --get-details: resolve [0x...] frames via `xcrun atos`",
|
|
1384
|
+
)
|
|
1385
|
+
parser.add_argument(
|
|
1386
|
+
"--app-binary",
|
|
1387
|
+
help="Path to unstripped app binary for --symbolicate (env: IOS_SIM_HANG_APP_BINARY)",
|
|
1388
|
+
)
|
|
1389
|
+
parser.add_argument(
|
|
1390
|
+
"--dsym",
|
|
1391
|
+
help="Path to .dSYM for --symbolicate (preferred over --app-binary; env: IOS_SIM_HANG_DSYM)",
|
|
1392
|
+
)
|
|
1393
|
+
parser.add_argument(
|
|
1394
|
+
"--older-than", help="With --clear-sessions: delete sessions older than e.g. 24h"
|
|
1395
|
+
)
|
|
1396
|
+
parser.add_argument("--terse", action="store_true", help="--stop: force L0 one-line output")
|
|
1397
|
+
|
|
1398
|
+
# Raw capture (HangBuster)
|
|
1399
|
+
parser.add_argument(
|
|
1400
|
+
"--raw-capture",
|
|
1401
|
+
action="store_true",
|
|
1402
|
+
help=(
|
|
1403
|
+
"With --start: capture raw os_log NDJSON to raw.ndjson instead of bucketed events. "
|
|
1404
|
+
"Explore afterwards with: zcat <session>/raw.ndjson.gz | jq ..."
|
|
1405
|
+
),
|
|
1406
|
+
)
|
|
1407
|
+
parser.add_argument(
|
|
1408
|
+
"--max-size-mb",
|
|
1409
|
+
type=int,
|
|
1410
|
+
default=10,
|
|
1411
|
+
help="Raw-capture per-session size cap in MB (default 10). Worker stops at cap.",
|
|
1412
|
+
)
|
|
1413
|
+
parser.add_argument(
|
|
1414
|
+
"--no-gzip",
|
|
1415
|
+
action="store_true",
|
|
1416
|
+
help="Skip gzip of raw.ndjson on --stop (raw-capture mode only).",
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
# Output
|
|
1420
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
1421
|
+
parser.add_argument("--verbose", action="store_true", help="Include raw lines (legacy modes)")
|
|
1422
|
+
|
|
1423
|
+
args = parser.parse_args()
|
|
1424
|
+
|
|
1425
|
+
# === HangBuster worker entry ===
|
|
1426
|
+
if args.worker_session_id:
|
|
1427
|
+
buster = HangBuster()
|
|
1428
|
+
sys.exit(buster.run_worker(args.worker_session_id))
|
|
1429
|
+
|
|
1430
|
+
# === HangBuster session subcommands ===
|
|
1431
|
+
if args.start:
|
|
1432
|
+
buster = HangBuster()
|
|
1433
|
+
start_args = {
|
|
1434
|
+
"min_hang_ms": (
|
|
1435
|
+
args.min_hang_ms
|
|
1436
|
+
if args.min_hang_ms is not None
|
|
1437
|
+
else env_int("IOS_SIM_HANG_MIN_MS", 250)
|
|
1438
|
+
),
|
|
1439
|
+
"bundle_id": args.bundle_id,
|
|
1440
|
+
"predicate": args.predicate,
|
|
1441
|
+
"auto_sample": args.auto_sample,
|
|
1442
|
+
"auto_spindump": args.auto_spindump,
|
|
1443
|
+
"raw_capture": args.raw_capture,
|
|
1444
|
+
"max_size_mb": args.max_size_mb,
|
|
1445
|
+
"no_gzip": args.no_gzip,
|
|
1446
|
+
}
|
|
1447
|
+
try:
|
|
1448
|
+
session_id = buster.start(start_args, udid=args.udid)
|
|
1449
|
+
except RuntimeError as error:
|
|
1450
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
1451
|
+
sys.exit(1)
|
|
1452
|
+
print(session_id)
|
|
1453
|
+
sys.exit(0)
|
|
1454
|
+
|
|
1455
|
+
if args.stop:
|
|
1456
|
+
buster = HangBuster()
|
|
1457
|
+
top_n = None if args.all_clusters else args.top_n
|
|
1458
|
+
out = buster.stop(
|
|
1459
|
+
args.stop,
|
|
1460
|
+
budget_tokens=args.budget_tokens,
|
|
1461
|
+
top_n=top_n,
|
|
1462
|
+
terse=args.terse,
|
|
1463
|
+
json_mode=args.json,
|
|
1464
|
+
)
|
|
1465
|
+
print(out)
|
|
1466
|
+
sys.exit(0)
|
|
1467
|
+
|
|
1468
|
+
if args.get_details:
|
|
1469
|
+
buster = HangBuster()
|
|
1470
|
+
out = buster.get_details(
|
|
1471
|
+
args.get_details,
|
|
1472
|
+
cluster=args.cluster,
|
|
1473
|
+
raw=args.raw,
|
|
1474
|
+
resample=args.resample,
|
|
1475
|
+
json_mode=args.json,
|
|
1476
|
+
symbolicate=args.symbolicate,
|
|
1477
|
+
app_binary=args.app_binary,
|
|
1478
|
+
dsym=args.dsym,
|
|
1479
|
+
)
|
|
1480
|
+
print(out)
|
|
1481
|
+
sys.exit(0)
|
|
1482
|
+
|
|
1483
|
+
if args.list_sessions:
|
|
1484
|
+
buster = HangBuster()
|
|
1485
|
+
print(buster.list_sessions(json_mode=args.json))
|
|
1486
|
+
sys.exit(0)
|
|
1487
|
+
|
|
1488
|
+
if args.clear_sessions:
|
|
1489
|
+
buster = HangBuster()
|
|
1490
|
+
print(buster.clear_sessions(older_than=args.older_than, json_mode=args.json))
|
|
1491
|
+
sys.exit(0)
|
|
1492
|
+
|
|
1493
|
+
if args.diff:
|
|
1494
|
+
buster = HangBuster()
|
|
1495
|
+
print(buster.diff(args.diff[0], args.diff[1], json_mode=args.json))
|
|
1496
|
+
sys.exit(0)
|
|
1497
|
+
|
|
1498
|
+
# === Legacy modes ===
|
|
1499
|
+
if args.since:
|
|
1500
|
+
try:
|
|
1501
|
+
_compute_start_timestamp(args.since)
|
|
1502
|
+
except ValueError as error:
|
|
1503
|
+
parser.error(str(error))
|
|
1504
|
+
|
|
1505
|
+
watcher = HangWatcher(udid=args.udid)
|
|
1506
|
+
if args.watch:
|
|
1507
|
+
success = watcher.watch(
|
|
1508
|
+
duration_seconds=args.duration,
|
|
1509
|
+
bundle_id=args.bundle_id,
|
|
1510
|
+
predicate=args.predicate,
|
|
1511
|
+
verbose=args.verbose,
|
|
1512
|
+
json_mode=args.json,
|
|
1513
|
+
)
|
|
1514
|
+
else:
|
|
1515
|
+
success = watcher.show_since(
|
|
1516
|
+
since_duration=args.since,
|
|
1517
|
+
bundle_id=args.bundle_id,
|
|
1518
|
+
predicate=args.predicate,
|
|
1519
|
+
verbose=args.verbose,
|
|
1520
|
+
json_mode=args.json,
|
|
1521
|
+
)
|
|
1522
|
+
if not success:
|
|
1523
|
+
sys.exit(1)
|
|
1524
|
+
if not args.json and not args.watch:
|
|
1525
|
+
print(f"\n{watcher.get_summary()}")
|
|
1526
|
+
if watcher.hang_events:
|
|
1527
|
+
cache_id = watcher.save_to_cache()
|
|
1528
|
+
print(f"Archive saved: {cache_id}", file=sys.stderr)
|
|
1529
|
+
sys.exit(0)
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
if __name__ == "__main__":
|
|
1533
|
+
main()
|