@leejungkiin/awkit 1.7.1 → 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.
Files changed (245) hide show
  1. package/bin/awk.js +576 -84
  2. package/core/CLAUDE.md +1 -1
  3. package/core/GEMINI.md +148 -167
  4. package/core/GEMINI.md.bak +149 -116
  5. package/core/skill-runtime-manifest.json +3 -0
  6. package/docs/Claude Fable 5.md +3826 -0
  7. package/docs/android_kotlin_system_instruction.md +210 -0
  8. package/docs/brainstorm_ponytail_integration.md +146 -0
  9. package/docs/brainstorm_smart_setup.md +113 -0
  10. package/docs/deep-research-report (1).md +293 -0
  11. package/docs/history/GEMINI.v1.md +135 -0
  12. package/docs/history/brainstorm_antigravity_unified_architecture.v1.md +105 -0
  13. package/docs/history/implementation_plan.v1.md +58 -0
  14. package/package.json +4 -1
  15. package/scripts/artifact-storage.js +130 -0
  16. package/scripts/automation-gate.js +35 -2
  17. package/scripts/claude-plan.js +76 -0
  18. package/scripts/dependency-manager.js +210 -0
  19. package/scripts/exec-rtk.js +11 -5
  20. package/scripts/i18n-helper.js +381 -0
  21. package/scripts/multi-model-pipeline.js +144 -0
  22. package/skill-packs/mobile-ios/pack.json +4 -2
  23. package/skill-packs/reverse-engineering/pack.json +1 -0
  24. package/skills/CATALOG.md +20 -0
  25. package/skills/GEMINI.md +9 -1
  26. package/skills/TRIGGER_INDEX.md +10 -0
  27. package/skills/ai-music/SKILL.md +275 -0
  28. package/skills/android-re-analyzer/SKILL.md +238 -0
  29. package/skills/android-re-analyzer/references/api-extraction-patterns.md +119 -0
  30. package/skills/android-re-analyzer/references/call-flow-analysis.md +176 -0
  31. package/skills/android-re-analyzer/references/fernflower-usage.md +115 -0
  32. package/skills/android-re-analyzer/references/jadx-usage.md +116 -0
  33. package/skills/android-re-analyzer/references/setup-guide.md +221 -0
  34. package/skills/android-re-analyzer/scripts/check-deps.sh +129 -0
  35. package/skills/android-re-analyzer/scripts/decompile.sh +375 -0
  36. package/skills/android-re-analyzer/scripts/find-api-calls.sh +118 -0
  37. package/skills/android-re-analyzer/scripts/install-dep.sh +448 -0
  38. package/skills/animal-island-ui-style/SKILL.md +1450 -0
  39. package/skills/app-store-review-agent/SKILL.md +164 -0
  40. package/skills/app-store-review-agent/references/guidelines/README.md +154 -0
  41. package/skills/app-store-review-agent/references/guidelines/by-app-type/ai_apps.md +37 -0
  42. package/skills/app-store-review-agent/references/guidelines/by-app-type/all_apps.md +50 -0
  43. package/skills/app-store-review-agent/references/guidelines/by-app-type/crypto_finance.md +31 -0
  44. package/skills/app-store-review-agent/references/guidelines/by-app-type/games.md +31 -0
  45. package/skills/app-store-review-agent/references/guidelines/by-app-type/health_fitness.md +31 -0
  46. package/skills/app-store-review-agent/references/guidelines/by-app-type/kids.md +27 -0
  47. package/skills/app-store-review-agent/references/guidelines/by-app-type/macos.md +38 -0
  48. package/skills/app-store-review-agent/references/guidelines/by-app-type/social_ugc.md +32 -0
  49. package/skills/app-store-review-agent/references/guidelines/by-app-type/subscription_iap.md +34 -0
  50. package/skills/app-store-review-agent/references/guidelines/by-app-type/vpn.md +18 -0
  51. package/skills/app-store-review-agent/references/rules/design/minimum_functionality.md +96 -0
  52. package/skills/app-store-review-agent/references/rules/design/sign_in_with_apple.md +54 -0
  53. package/skills/app-store-review-agent/references/rules/entitlements/unused_entitlements.md +83 -0
  54. package/skills/app-store-review-agent/references/rules/metadata/accurate_metadata.md +54 -0
  55. package/skills/app-store-review-agent/references/rules/metadata/apple_trademark.md +99 -0
  56. package/skills/app-store-review-agent/references/rules/metadata/china_storefront.md +72 -0
  57. package/skills/app-store-review-agent/references/rules/metadata/competitor_terms.md +56 -0
  58. package/skills/app-store-review-agent/references/rules/metadata/subscription_metadata.md +81 -0
  59. package/skills/app-store-review-agent/references/rules/privacy/privacy_manifest.md +84 -0
  60. package/skills/app-store-review-agent/references/rules/privacy/unnecessary_data.md +60 -0
  61. package/skills/app-store-review-agent/references/rules/subscription/misleading_pricing.md +63 -0
  62. package/skills/app-store-review-agent/references/rules/subscription/missing_tos_pp.md +54 -0
  63. package/skills/awf-ponytail/SKILL.md +91 -0
  64. package/skills/awf-ponytail-review/SKILL.md +67 -0
  65. package/skills/awf-session-restore/SKILL.md +3 -3
  66. package/skills/brainstorm-agent/SKILL.md +11 -2
  67. package/skills/brainstorm-agent/templates/brief-template.md +8 -0
  68. package/skills/claude-planner/SKILL.md +47 -0
  69. package/skills/code-review/SKILL.md +87 -0
  70. package/skills/expo-game-development/SKILL.md +163 -0
  71. package/skills/flutter/LICENSE.txt +202 -0
  72. package/skills/flutter/SKILL.md +127 -0
  73. package/skills/flutter-project-creater/LICENSE.txt +202 -0
  74. package/skills/flutter-project-creater/SKILL.md +106 -0
  75. package/skills/game-developer/SKILL.md +163 -0
  76. package/skills/game-developer/references/ecs-patterns.md +501 -0
  77. package/skills/game-developer/references/multiplayer-networking.md +475 -0
  78. package/skills/game-developer/references/performance-optimization.md +422 -0
  79. package/skills/game-developer/references/unity-patterns.md +271 -0
  80. package/skills/game-developer/references/unreal-cpp.md +352 -0
  81. package/skills/generate-gui-assets/SKILL.md +305 -0
  82. package/skills/generate-gui-assets/agents/openai.yaml +4 -0
  83. package/skills/generate-gui-assets/references/catalog-schema.md +58 -0
  84. package/skills/generate-gui-assets/references/extraction-techniques.md +21 -0
  85. package/skills/generate-gui-assets/references/prompt-patterns.md +58 -0
  86. package/skills/generate-gui-assets/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
  87. package/skills/generate-gui-assets/scripts/build_gui_contact_sheet.py +51 -0
  88. package/skills/generate-gui-assets/scripts/clean_chroma_edges.py +262 -0
  89. package/skills/generate-gui-assets/scripts/copy_approved_icons.py +64 -0
  90. package/skills/generate-gui-assets/scripts/prepare_gui_asset_run.py +91 -0
  91. package/skills/generate-gui-assets/scripts/suggest_grid_options.py +63 -0
  92. package/skills/generate-gui-assets/scripts/validate_gui_catalog.py +50 -0
  93. package/skills/godot-game-development/SKILL.md +142 -0
  94. package/skills/hatch-pet/LICENSE.txt +201 -0
  95. package/skills/hatch-pet/SKILL.md +420 -0
  96. package/skills/hatch-pet/agents/openai.yaml +4 -0
  97. package/skills/hatch-pet/references/animation-rows.md +29 -0
  98. package/skills/hatch-pet/references/codex-pet-contract.md +35 -0
  99. package/skills/hatch-pet/references/qa-rubric.md +60 -0
  100. package/skills/hatch-pet/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
  101. package/skills/hatch-pet/scripts/clean_chroma_edges.py +262 -0
  102. package/skills/hatch-pet/scripts/compose_atlas.py +150 -0
  103. package/skills/hatch-pet/scripts/derive_running_left_from_running_right.py +143 -0
  104. package/skills/hatch-pet/scripts/extract_strip_frames.py +323 -0
  105. package/skills/hatch-pet/scripts/finalize_pet_run.py +382 -0
  106. package/skills/hatch-pet/scripts/generate_pet_images.py +287 -0
  107. package/skills/hatch-pet/scripts/inspect_frames.py +246 -0
  108. package/skills/hatch-pet/scripts/make_contact_sheet.py +96 -0
  109. package/skills/hatch-pet/scripts/package_custom_pet.py +108 -0
  110. package/skills/hatch-pet/scripts/pet_job_status.py +117 -0
  111. package/skills/hatch-pet/scripts/prepare_pet_run.py +673 -0
  112. package/skills/hatch-pet/scripts/queue_pet_repairs.py +172 -0
  113. package/skills/hatch-pet/scripts/record_imagegen_result.py +250 -0
  114. package/skills/hatch-pet/scripts/render_animation_videos.py +134 -0
  115. package/skills/hatch-pet/scripts/render_animation_videos.sh +5 -0
  116. package/skills/hatch-pet/scripts/validate_atlas.py +139 -0
  117. package/skills/i18n-orchestrator/SKILL.md +37 -0
  118. package/skills/ios-simulator-skill/SKILL.md +390 -0
  119. package/skills/ios-simulator-skill/scripts/accessibility_audit.py +300 -0
  120. package/skills/ios-simulator-skill/scripts/app_launcher.py +326 -0
  121. package/skills/ios-simulator-skill/scripts/app_state_capture.py +400 -0
  122. package/skills/ios-simulator-skill/scripts/appearance.py +385 -0
  123. package/skills/ios-simulator-skill/scripts/build_and_test.py +348 -0
  124. package/skills/ios-simulator-skill/scripts/clipboard.py +103 -0
  125. package/skills/ios-simulator-skill/scripts/common/__init__.py +61 -0
  126. package/skills/ios-simulator-skill/scripts/common/cache_utils.py +289 -0
  127. package/skills/ios-simulator-skill/scripts/common/device_utils.py +462 -0
  128. package/skills/ios-simulator-skill/scripts/common/env_config.py +35 -0
  129. package/skills/ios-simulator-skill/scripts/common/hang_pipeline.py +862 -0
  130. package/skills/ios-simulator-skill/scripts/common/hang_sessions.py +490 -0
  131. package/skills/ios-simulator-skill/scripts/common/idb_utils.py +180 -0
  132. package/skills/ios-simulator-skill/scripts/common/screenshot_utils.py +338 -0
  133. package/skills/ios-simulator-skill/scripts/container.py +668 -0
  134. package/skills/ios-simulator-skill/scripts/gesture.py +394 -0
  135. package/skills/ios-simulator-skill/scripts/hang_watcher.py +1533 -0
  136. package/skills/ios-simulator-skill/scripts/keyboard.py +391 -0
  137. package/skills/ios-simulator-skill/scripts/localization_audit.py +483 -0
  138. package/skills/ios-simulator-skill/scripts/location.py +467 -0
  139. package/skills/ios-simulator-skill/scripts/log_monitor.py +493 -0
  140. package/skills/ios-simulator-skill/scripts/model_inspector.py +645 -0
  141. package/skills/ios-simulator-skill/scripts/navigator.py +461 -0
  142. package/skills/ios-simulator-skill/scripts/privacy_manager.py +310 -0
  143. package/skills/ios-simulator-skill/scripts/push_notification.py +240 -0
  144. package/skills/ios-simulator-skill/scripts/screen_mapper.py +296 -0
  145. package/skills/ios-simulator-skill/scripts/sim_health_check.sh +245 -0
  146. package/skills/ios-simulator-skill/scripts/sim_list.py +299 -0
  147. package/skills/ios-simulator-skill/scripts/simctl_boot.py +312 -0
  148. package/skills/ios-simulator-skill/scripts/simctl_create.py +316 -0
  149. package/skills/ios-simulator-skill/scripts/simctl_delete.py +357 -0
  150. package/skills/ios-simulator-skill/scripts/simctl_erase.py +351 -0
  151. package/skills/ios-simulator-skill/scripts/simctl_shutdown.py +290 -0
  152. package/skills/ios-simulator-skill/scripts/simulator_selector.py +375 -0
  153. package/skills/ios-simulator-skill/scripts/status_bar.py +250 -0
  154. package/skills/ios-simulator-skill/scripts/test_recorder.py +323 -0
  155. package/skills/ios-simulator-skill/scripts/visual_diff.py +235 -0
  156. package/skills/ios-simulator-skill/scripts/xcode/__init__.py +13 -0
  157. package/skills/ios-simulator-skill/scripts/xcode/builder.py +397 -0
  158. package/skills/ios-simulator-skill/scripts/xcode/cache.py +204 -0
  159. package/skills/ios-simulator-skill/scripts/xcode/config.py +178 -0
  160. package/skills/ios-simulator-skill/scripts/xcode/reporter.py +343 -0
  161. package/skills/ios-simulator-skill/scripts/xcode/xcresult.py +451 -0
  162. package/skills/ios-visual-qa-strategist/SKILL.md +111 -0
  163. package/skills/ios-visual-qa-strategist/agents/openai.yaml +4 -0
  164. package/skills/ios-visual-qa-strategist/references/ios-tool-selection.md +61 -0
  165. package/skills/ios-visual-qa-strategist/references/minimal-capture-policy.md +56 -0
  166. package/skills/ios-visual-qa-strategist/references/visual-reasoning-heuristics.md +53 -0
  167. package/skills/orchestrator/SKILL.md +0 -20
  168. package/skills/persistent-storage/SKILL.md +55 -0
  169. package/skills/short-maker/SKILL.md +23 -0
  170. package/skills/short-maker/scripts/effects.js +56 -0
  171. package/skills/short-maker/scripts/shortmaker-bridge.js +332 -0
  172. package/skills/short-maker/scripts/videomix.js +601 -0
  173. package/skills/short-maker/templates/hyperframes/cinematic-character.template.html +172 -0
  174. package/skills/short-maker/templates/hyperframes/index.template.html +194 -0
  175. package/skills/smali-to-kotlin/SKILL.md +128 -0
  176. package/skills/smali-to-kotlin/examples/getting-started/tech-stack.md +58 -0
  177. package/skills/smali-to-kotlin/examples/pipeline/data-ui-parity.md +118 -0
  178. package/skills/smali-to-kotlin/examples/pipeline/scanner-and-bootstrap.md +106 -0
  179. package/skills/smali-to-kotlin/library-patterns.md +189 -0
  180. package/skills/smali-to-kotlin/phase-0-discovery.md +128 -0
  181. package/skills/smali-to-kotlin/phase-1-architecture.md +166 -0
  182. package/skills/smali-to-kotlin/phase-2-blueprint-ui.md +347 -0
  183. package/skills/smali-to-kotlin/phase-2-blueprint.md +228 -0
  184. package/skills/smali-to-kotlin/phase-3-build.md +248 -0
  185. package/skills/smali-to-kotlin/phase-3-logic-build.md +268 -0
  186. package/skills/smali-to-kotlin/smali-reading-guide.md +310 -0
  187. package/skills/smali-to-kotlin/templates/app-map.md +101 -0
  188. package/skills/smali-to-kotlin/templates/architecture.md +142 -0
  189. package/skills/smali-to-kotlin/templates/blueprint.md +145 -0
  190. package/skills/spec-gate/SKILL.md +6 -2
  191. package/skills/symphony-enforcer/SKILL.md +8 -0
  192. package/skills/symphony-enforcer/examples/mindful-stop.md +2 -0
  193. package/skills/symphony-enforcer/examples/three-phase.md +16 -0
  194. package/skills/symphony-enforcer/examples/trigger-points.md +7 -1
  195. package/skills/unity-game-development/SKILL.md +231 -0
  196. package/skills/video-edit/SKILL.md +36 -0
  197. package/skills/video-edit/scripts/video_edit.py +324 -0
  198. package/templates/project-identity/android.json +2 -2
  199. package/templates/project-identity/backend-nestjs.json +2 -2
  200. package/templates/project-identity/expo.json +2 -2
  201. package/templates/project-identity/ios.json +2 -2
  202. package/templates/project-identity/web-nextjs.json +2 -2
  203. package/templates/setup-mapping.json +48 -0
  204. package/templates/specs/design-template.md +161 -71
  205. package/templates/specs/requirements-template.md +65 -133
  206. package/templates/specs/task-spec-template.xml +3 -0
  207. package/workflows/_uncategorized/critic.md +40 -0
  208. package/workflows/_uncategorized/git-rebase-flow.md +81 -0
  209. package/workflows/_uncategorized/image-gen.md +118 -0
  210. package/workflows/_uncategorized/multi-model-pipeline.md +60 -0
  211. package/workflows/_uncategorized/pixel-gen.md +86 -0
  212. package/workflows/_uncategorized/pixel-setup.md +90 -0
  213. package/workflows/_uncategorized/ponytail-review.md +59 -0
  214. package/workflows/_uncategorized/reverse-android-build.md +222 -0
  215. package/workflows/_uncategorized/reverse-android-design.md +139 -0
  216. package/workflows/_uncategorized/reverse-android-discover.md +150 -0
  217. package/workflows/_uncategorized/reverse-android-scan.md +158 -0
  218. package/workflows/_uncategorized/reverse-android.md +143 -0
  219. package/workflows/_uncategorized/reverse-ios-build.md +240 -0
  220. package/workflows/_uncategorized/reverse-ios-design.md +112 -0
  221. package/workflows/_uncategorized/reverse-ios-discover.md +120 -0
  222. package/workflows/_uncategorized/reverse-ios-scan.md +155 -0
  223. package/workflows/_uncategorized/reverse-ios.md +152 -0
  224. package/workflows/_uncategorized/safety-router.md +34 -0
  225. package/workflows/_uncategorized/teach.md +89 -0
  226. package/workflows/_uncategorized/verify-ui.md +53 -0
  227. package/workflows/_uncategorized/visualize-screenshots.md +34 -0
  228. package/workflows/ads/ads-analyst.md +201 -0
  229. package/workflows/ads/ads-audit.md +106 -0
  230. package/workflows/ads/ads-optimize.md +97 -0
  231. package/workflows/ads/ads-targeting.md +241 -0
  232. package/workflows/ads/adsExpert.md +160 -0
  233. package/workflows/ads/smali-ads-config.md +400 -0
  234. package/workflows/ads/smali-ads-flow.md +331 -0
  235. package/workflows/ads/smali-ads-interstitial.md +377 -0
  236. package/workflows/ads/smali-ads-native.md +382 -0
  237. package/workflows/context/teach.md +89 -0
  238. package/workflows/gitnexus.md +8 -8
  239. package/workflows/lifecycle/brainstorm.md +43 -0
  240. package/workflows/lifecycle/code.md +5 -0
  241. package/workflows/lifecycle/init.md +23 -5
  242. package/workflows/lifecycle/multi-model-pipeline.md +60 -0
  243. package/workflows/quality/ponytail-review.md +59 -0
  244. package/workflows/roles/critic.md +40 -0
  245. 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()