meltygui 0.1.0__py3-none-any.whl

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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1163 @@
1
+ """Deliberate garbage-collection scheduling for the studio.
2
+
3
+ The stock collector runs generation-2 passes at arbitrary allocation points —
4
+ observed as a 3.3s GIL-held stall (613k objects collected) landing on the
5
+ render thread mid-typing, with several large cst-dict graphs resident. Three
6
+ measures, all Toggles.GC-gated, applied from the per-frame tick() (hooked in
7
+ Melty.end_frame):
8
+
9
+ * thresholds — gen2's auto-trigger is pushed effectively out of reach
10
+ (gen0/gen1 stay stock: young-object passes are cheap), so full
11
+ collections only happen when WE schedule them;
12
+ * boot freeze — at the first input-idle window after boot, one full
13
+ collect then gc.freeze(): the stable app graph (modules, fonts, studio,
14
+ parse caches) moves to the permanent generation and is never walked
15
+ again. Cycles alive at freeze time are leaked by design — app-lifetime
16
+ state doesn't care;
17
+ * idle collects — while input stays quiet, a periodic gc.collect() drains
18
+ the cyclic garbage editing accumulates. Post-freeze the pass only walks
19
+ objects allocated since, so it is small — and it lands when nobody is
20
+ typing.
21
+
22
+ Every pass reports through the "lag" notify column: its duration, how many
23
+ objects (and roughly how many bytes) went, and the top types — and the toast
24
+ is clickable: it opens that collect's REPORT (one file per collect under
25
+ Toggles.GC.report_dir: type / module / dict-key-signature / function / frame
26
+ histograms of the reclaimed cycles, sample reprs, and the scheduler's reason)
27
+ in the code editor. Toggles.memory_profile additionally histograms, for the
28
+ boot pass, the whole live graph about to be frozen, appended to
29
+ /tmp/lsd_gc_profile.log. State survives hotswap via the globals().get pattern; the
30
+ end_frame hook line in meltygui.py is restart-bound (meltygui never hotswaps).
31
+ """
32
+ import gc
33
+ import time
34
+
35
+ from meltygui.core.runtime.paths import debug_log_path
36
+ from meltygui.core.diagnostics.notifications import notify
37
+ from meltygui.core.diagnostics.notifications import capture_stack
38
+ from meltygui.core.runtime.toggles import Toggles
39
+
40
+ _state = globals().get("_state") or {
41
+ "applied": False, # thresholds currently overridden
42
+ "frozen": False, # boot collect+freeze done
43
+ "last_collect": 0.0,
44
+ "boot_t": time.monotonic(),
45
+ "last_tick": 0.0, # frame gap detection (frames park in wait_events)
46
+ "focused": True, # glfw FOCUSED as of the last tick
47
+ "resumed_t": 0.0, # last focus-gain / frame-gap moment
48
+ "unfocus_armed_t": 0.0, # focus-LOSS edge seen; not once it is confirmed
49
+ }
50
+ # Hotswap reuses the live _state dict - backfill fields added since.
51
+ for _k, _v in (("last_tick", 0.0), ("focused", True), ("resumed_t", 0.0),
52
+ ("unfocus_armed_t", 0.0)):
53
+ _state.setdefault(_k, _v)
54
+
55
+ PROFILE_LOG = debug_log_path("lsd_gc_profile.log")
56
+
57
+
58
+ def _profile_enabled():
59
+ from meltygui.core.runtime.toggles import Toggles
60
+ return bool(Toggles.memory_profile)
61
+
62
+
63
+ def _type_name(o):
64
+ t = type(o)
65
+ mod = getattr(t, "__module__", "") or ""
66
+ return f"{mod}.{t.__qualname__}" if mod not in ("builtins", "") else t.__qualname__
67
+
68
+
69
+ def _histogram(objs, top=30):
70
+ from collections import Counter
71
+ c = Counter()
72
+ for o in objs:
73
+ c[_type_name(o)] += 1
74
+ return c.most_common(top), sum(c.values())
75
+
76
+
77
+ def _samples(objs, wanted, per_type=3, width=160):
78
+ """A few truncated reprs per type so 'dict' / 'list' rows say WHICH dicts.
79
+ Plain containers get their key/element summary instead of repr()."""
80
+ out = {}
81
+ for o in objs:
82
+ tn = _type_name(o)
83
+ if tn not in wanted:
84
+ continue
85
+ got = out.setdefault(tn, [])
86
+ if len(got) >= per_type:
87
+ continue
88
+ try:
89
+ if type(o) is dict:
90
+ r = "dict keys=" + repr(list(o.keys())[:8])
91
+ elif type(o) in (list, tuple, set):
92
+ r = f"{tn}[{len(o)}] " + repr(o[:4] if type(o) is not set else list(o)[:4])
93
+ elif hasattr(o, "shape") and hasattr(o, "dtype"):
94
+ # A tensor / ndarray repr materializes (and allocs) the data.
95
+ r = f"{tn} shape={tuple(o.shape)} dtype={o.dtype}"
96
+ else:
97
+ r = repr(o)
98
+ if " object at 0x" in r and hasattr(o, "__dict__"):
99
+ r += " attrs=" + repr(list(vars(o).keys())[:10])
100
+ except Exception as e:
101
+ r = f"<repr failed: {e!r}>"
102
+ got.append(r.replace("\n", " ")[:width])
103
+ return out
104
+
105
+
106
+ def _write(lines):
107
+ try:
108
+ with open(PROFILE_LOG, "a") as f:
109
+ f.write("\n".join(lines) + "\n")
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ def _report_dir():
115
+ """Where per-collect reports go — `Toggles.GC.report_dir` (expanded),
116
+ falling back to the in-repo `.melty/gc_reports` (the screenshot
117
+ convention). Created on demand; None if neither is writable."""
118
+ import os
119
+ from pathlib import Path
120
+ from meltygui.core.runtime.toggles import Toggles
121
+ candidates = []
122
+ configured = Toggles.GC.report_dir
123
+ if configured:
124
+ candidates.append(Path(configured).expanduser())
125
+ from meltygui.core.runtime.paths import cache_root
126
+ candidates.append(cache_root() / "gc_reports")
127
+ for d in candidates:
128
+ try:
129
+ d.mkdir(parents=True, exist_ok=True)
130
+ if os.access(d, os.W_OK):
131
+ return d
132
+ except Exception:
133
+ continue
134
+ return None
135
+
136
+
137
+ def _write_report(label, lines):
138
+ """One file per collect (`gc_<HHMMSS>_<label>.txt`), oldest pruned past
139
+ `Toggles.GC.report_keep`. Returns the path, or None."""
140
+ import re
141
+ from meltygui.core.runtime.toggles import Toggles
142
+ d = _report_dir()
143
+ if d is None:
144
+ return None
145
+ slug = re.sub(r"[^A-Za-z0-9]+", "_", label).strip("_") or "collect"
146
+ path = d / f"gc_{time.strftime('%Y%m%d_%H%M%S')}_{slug}.txt"
147
+ try:
148
+ path.write_text("\n".join(lines) + "\n")
149
+ keep = int(Toggles.GC.report_keep or 0)
150
+ if keep > 0:
151
+ old = sorted(d.glob("gc_*.txt"))[:-keep]
152
+ for f in old:
153
+ f.unlink(missing_ok=True)
154
+ except Exception:
155
+ return None
156
+ return path
157
+
158
+
159
+ def _detail_rows(objs, top=15):
160
+ """WHAT the reclaimed objects were, one level below the type histogram:
161
+ dicts grouped by their key signature (which dicts), functions by
162
+ qualname, frames / code / cells by the code they belong to, methods by
163
+ their function. The type histogram says "6,000 dicts"; these rows say
164
+ "4,100 of them are draw_state kwargs dicts"."""
165
+ import types
166
+ from collections import Counter
167
+ dicts, funcs, frames, cells, methods, code_objs = (Counter() for _ in range(6))
168
+ for o in objs:
169
+ t = type(o)
170
+ try:
171
+ if t is dict:
172
+ keys = list(o.keys())
173
+ sig = ", ".join(str(k)[:24] for k in keys[:5])
174
+ if len(keys) > 5:
175
+ sig += f", … (+{len(keys) - 5})"
176
+ dicts[f"{{{sig}}}"] += 1
177
+ elif t is types.FunctionType:
178
+ funcs[f"{o.__module__}.{o.__qualname__}"] += 1
179
+ elif t is types.FrameType:
180
+ c = o.f_code
181
+ frames[f"{c.co_name} {c.co_filename.rsplit('/', 1)[-1]}:{o.f_lineno}"] += 1
182
+ elif t is types.CellType:
183
+ cells[_type_name(o.cell_contents) if o.cell_contents is not None else "None"] += 1
184
+ elif t is types.MethodType:
185
+ f = o.__func__
186
+ methods[f"{getattr(f, '__module__', '?')}.{getattr(f, '__qualname__', '?')}"] += 1
187
+ elif t is types.CodeType:
188
+ code_objs[f"{o.co_name} {o.co_filename.rsplit('/', 1)[-1]}:{o.co_firstlineno}"] += 1
189
+ except Exception:
190
+ continue
191
+ lines = []
192
+ for title, counter in (("dicts by key signature", dicts),
193
+ ("functions by qualname", funcs),
194
+ ("bound methods by function", methods),
195
+ ("frames by code site", frames),
196
+ ("code objects", code_objs),
197
+ ("cells by content type", cells)):
198
+ if not counter:
199
+ continue
200
+ lines.append(f"--- {title} (top {top} of {len(counter)} distinct):")
201
+ for name, n in counter.most_common(top):
202
+ lines.append(f" {n:>10} {name}")
203
+ return lines
204
+
205
+
206
+ def _module_histogram(objs, top=15):
207
+ """The reclaimed objects by the MODULE their type was defined in — the
208
+ quickest "whose garbage is this" read (src.lsd… vs libcst vs torch)."""
209
+ from collections import Counter
210
+ c = Counter()
211
+ for o in objs:
212
+ c[getattr(type(o), "__module__", "") or "builtins"] += 1
213
+ return [f"--- by type's module (top {top}):"] + [
214
+ f" {n:>10} {m}" for m, n in c.most_common(top)]
215
+
216
+
217
+ def _approx_bytes(objs):
218
+ """sys.getsizeof over the reclaimed objects — shallow (no referents
219
+ that survive elsewhere), so a lower bound on what the collect freed."""
220
+ import sys
221
+ total = 0
222
+ for o in objs:
223
+ try:
224
+ total += sys.getsizeof(o)
225
+ except Exception:
226
+ pass
227
+ return total
228
+
229
+
230
+ def _fmt_bytes(n):
231
+ for unit in ("B", "KB", "MB", "GB"):
232
+ if n < 1024 or unit == "GB":
233
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
234
+ n /= 1024.0
235
+
236
+
237
+ def _thread_report():
238
+ """One line per thread: name + the innermost src.* frame's module, tagged
239
+ STALE when that module dict is no longer the one registered in
240
+ sys.modules — i.e. a thread left over from a previous in-process session,
241
+ pinning that session's whole graph (see lifecycle.module_is_live)."""
242
+ import sys
243
+ import threading
244
+ from meltygui.core.runtime.lifecycle import module_is_live
245
+ frames = sys._current_frames()
246
+ lines = ["--- THREADS (STALE = running in a purged prior-session module):"]
247
+ stale = 0
248
+ for th in threading.enumerate():
249
+ f = frames.get(th.ident)
250
+ where, tag = "?", ""
251
+ while f is not None:
252
+ g = f.f_globals
253
+ name = g.get("__name__", "")
254
+ if name.startswith("src."):
255
+ where = f"{name}:{f.f_code.co_name}:{f.f_lineno}"
256
+ if not module_is_live(g):
257
+ tag = " STALE"
258
+ stale += 1
259
+ break
260
+ f = f.f_back
261
+ lines.append(f" {th.name:<40} {where}{tag}")
262
+ lines.append(f" ({stale} stale of {len(lines) - 1})")
263
+ return lines
264
+
265
+
266
+ def _describe_root(o, world):
267
+ """Short label for a live object that points into the stale world: what it
268
+ is, and (for dicts / module dicts / frames) WHICH slot does the pointing."""
269
+ import sys
270
+ import types
271
+ t = type(o)
272
+ if t is dict:
273
+ name = o.get("__name__") if "__file__" in o or "__spec__" in o else None
274
+ keys = [k for k, v in list(o.items())[:4000] if id(v) in world][:4]
275
+ if isinstance(name, str):
276
+ return f"module dict {name} keys={keys}"
277
+ return f"dict keys={keys} (of {len(o)})"
278
+ if t is types.FrameType:
279
+ return f"frame {o.f_globals.get('__name__')}:{o.f_code.co_name}"
280
+ if t is types.FunctionType:
281
+ return f"function {o.__module__}.{o.__qualname__}"
282
+ if t is types.MethodType:
283
+ return f"method {type(o.__self__).__name__}.{o.__func__.__qualname__}"
284
+ if t is types.CellType:
285
+ return "cell"
286
+ if t in (list, tuple, set):
287
+ return f"{t.__name__}[{len(o)}]"
288
+ return _type_name(o)
289
+
290
+
291
+ def _stale_world_report(live):
292
+ """Which purged src.* modules are still alive, how big the graph hanging
293
+ off them is, and — the actual answer — which LIVE objects point into it.
294
+ Seeds: module objects named src.* that sys.modules no longer maps to.
295
+ World: everything reachable from the seeds WITHOUT crossing into live
296
+ modules / live module dicts / the sys._* shared stores. Roots: objects
297
+ outside the world with a direct referent inside it."""
298
+ import sys
299
+ import types
300
+ from collections import Counter
301
+ # Seeds are module DICTS: the module object itself usually dies with the
302
+ # purge, but its module dict lives on in every function's __globals__.
303
+ live_mod_dicts = {id(vars(m)) for m in list(sys.modules.values())
304
+ if hasattr(m, "__dict__")}
305
+
306
+ def _stale_mod_name(d):
307
+ if type(d) is not dict or id(d) in live_mod_dicts or "__spec__" not in d:
308
+ return None
309
+ n = d.get("__name__")
310
+ return n if isinstance(n, str) and n.startswith("src.") else None
311
+
312
+ def _stale_type(t):
313
+ """A class is stale when the module it names no longer binds it under
314
+ its qualname — the live class (even if reachable from old data via
315
+ shared stores) must NOT be crossed, or every live instance of it
316
+ reads as a root."""
317
+ modname = getattr(t, "__module__", None)
318
+ if not (isinstance(modname, str) and modname.startswith("src.")):
319
+ return False
320
+ obj = sys.modules.get(modname)
321
+ if obj is None:
322
+ return True
323
+ for part in t.__qualname__.split("."):
324
+ obj = getattr(obj, part, None)
325
+ if obj is None:
326
+ return True
327
+ return obj is not t
328
+
329
+ _NO_CROSS = (types.BuiltinFunctionType, types.MethodDescriptorType,
330
+ types.WrapperDescriptorType, types.GetSetDescriptorType,
331
+ types.MemberDescriptorType, types.ClassMethodDescriptorType)
332
+
333
+ def _crossable(r):
334
+ """Follow into session-owned data only — never into process-shared
335
+ singletons (builtin/foreign classes, foreign functions, descriptors,
336
+ atoms), which would make every live object look like a 'root'."""
337
+ if not gc.is_tracked(r):
338
+ return False
339
+ t = type(r)
340
+ if t is types.ModuleType:
341
+ n = getattr(r, "__name__", None)
342
+ return isinstance(n, str) and n.startswith("src.") and sys.modules.get(n) is not r
343
+ if t is dict:
344
+ if "__spec__" in r and isinstance(r.get("__name__"), str):
345
+ return _stale_mod_name(r) is not None
346
+ return True
347
+ if isinstance(r, type):
348
+ return _stale_type(r)
349
+ if t is types.FunctionType:
350
+ return _stale_mod_name(r.__globals__) is not None
351
+ if t in _NO_CROSS:
352
+ return False
353
+ if t.__module__ == "ast" and t.__name__ in ("Load", "Store", "Del"):
354
+ return False # process-shared singletons: every live ast node points at them
355
+ return True
356
+
357
+ seeds = [o for o in live if _stale_mod_name(o)]
358
+ if not seeds:
359
+ return ["--- STALE MODULES: none alive"]
360
+ lines = [f"--- STALE MODULES (purged src.* module dicts still alive): {len(seeds)}"]
361
+ for name, n in Counter(d["__name__"] for d in seeds).most_common(12):
362
+ lines.append(f" {n:>4} {name}")
363
+ shared = set()
364
+ for k, v in list(vars(sys).items()):
365
+ if k.startswith("_"):
366
+ shared.add(id(v))
367
+ try:
368
+ shared.update(id(r) for r in gc.get_referents(v))
369
+ except Exception:
370
+ pass
371
+ skip = live_mod_dicts | shared | {id(live), id(seeds)}
372
+ world, stack = set(), list(seeds)
373
+ t0 = time.perf_counter()
374
+ while stack:
375
+ o = stack.pop()
376
+ if id(o) in world:
377
+ continue
378
+ world.add(id(o))
379
+ for r in gc.get_referents(o):
380
+ rid = id(r)
381
+ if rid in world or rid in skip or not _crossable(r):
382
+ continue
383
+ stack.append(r)
384
+ wc = Counter()
385
+ for o in live:
386
+ if id(o) in world:
387
+ wc[_type_name(o)] += 1
388
+ lines.append(f"--- STALE WORLD: {len(world)} objects reachable from them "
389
+ f"({1000*(time.perf_counter()-t0):.0f}ms); top types:")
390
+ for tn, n in wc.most_common(15):
391
+ lines.append(f" {n:>9} {tn}")
392
+ t0 = time.perf_counter()
393
+ roots = Counter()
394
+ examples = {}
395
+ me = globals()
396
+ for o in live:
397
+ oid = id(o)
398
+ if oid in world or o is live or o is seeds:
399
+ continue
400
+ if type(o) is types.FrameType and o.f_globals is me:
401
+ continue
402
+ if type(o) in _NO_CROSS:
403
+ continue # slot/getset descriptors of the old classes: not holders
404
+ try:
405
+ refs = gc.get_referents(o)
406
+ except Exception:
407
+ continue
408
+ if any(id(r) in world for r in refs):
409
+ try:
410
+ d = _describe_root(o, world)
411
+ except Exception as e:
412
+ d = f"{_type_name(o)} <describe failed {e!r}>"
413
+ roots[d] += 1
414
+ examples.setdefault(d, o)
415
+ lines.append(f"--- ROOTS (live objects pointing INTO the stale world): "
416
+ f"{sum(roots.values())} ({1000*(time.perf_counter()-t0):.0f}ms)")
417
+ # Whose dict is it? One get_referrers per top dict root (each is a full
418
+ # heap scan, so capped): find instance/class whose __dict__ is that dict.
419
+ owners = {}
420
+ budget = 8
421
+ for d, n in roots.most_common(40):
422
+ ex = examples.get(d)
423
+ if budget <= 0 or type(ex) is not dict or d.startswith("module dict"):
424
+ continue
425
+ budget -= 1
426
+ try:
427
+ for r in gc.get_referrers(ex):
428
+ if r is live or r is seeds:
429
+ continue
430
+ if getattr(r, "__dict__", None) is ex:
431
+ owners[d] = f"{_type_name(r)}" if not isinstance(r, type) \
432
+ else f"class {r.__module__}.{r.__qualname__}"
433
+ break
434
+ if type(r) is dict:
435
+ ks = [k for k, v in list(r.items())[:2000] if v is ex][:2]
436
+ owners[d] = f"nested under dict keys={ks}"
437
+ except Exception:
438
+ pass
439
+ for d, n in roots.most_common(40):
440
+ own = owners.get(d)
441
+ lines.append(f" {n:>6} {d[:150]}" + (f" <- {own}" if own else ""))
442
+ # Upward anchor chains for the top roots: who holds the holder, up to a
443
+ # named anchor (module global / sys attribute / class attribute / thread
444
+ # frame). Each hop is a full-heap get_referrers, so this is time-budgeted.
445
+ lines.append("--- ANCHOR CHAINS (top roots, upward; budgeted):")
446
+ deadline = time.perf_counter() + 10.0
447
+ sys_attrs = {id(v): k for k, v in list(vars(sys).items())}
448
+ mod_dict_names = {id(vars(m)): n for n, m in list(sys.modules.items())
449
+ if hasattr(m, "__dict__")}
450
+ own_structs = {id(live), id(seeds), id(roots), id(examples), id(owners)}
451
+ chased = 0
452
+ for d, n in roots.most_common(12):
453
+ if chased >= 4 or time.perf_counter() > deadline:
454
+ break
455
+ ex = examples.get(d)
456
+ if ex is None or d.startswith("module dict"):
457
+ continue
458
+ chased += 1
459
+ chain = [f"{d[:90]} (x{n})"]
460
+ cur = ex
461
+ visited = {id(cur)}
462
+ for _hop in range(6):
463
+ if time.perf_counter() > deadline:
464
+ chain.append("… (time budget)")
465
+ break
466
+ if id(cur) in sys_attrs:
467
+ chain.append(f"sys.{sys_attrs[id(cur)]}")
468
+ break
469
+ if id(cur) in mod_dict_names:
470
+ chain.append(f"module {mod_dict_names[id(cur)]} globals")
471
+ break
472
+ try:
473
+ refs = [r for r in gc.get_referrers(cur)
474
+ if id(r) not in own_structs and id(r) not in visited
475
+ and id(r) not in world
476
+ and not (type(r) is types.FrameType and r.f_globals is me)]
477
+ except Exception:
478
+ break
479
+ if not refs:
480
+ chain.append("(no live referrer — held only from inside the stale world)")
481
+ break
482
+ # Prefer the most "anchored" referrer: frames, module dicts, sys
483
+ # values, classes first, plain containers after.
484
+ def _rank(r):
485
+ if type(r) is types.FrameType: return 0
486
+ if id(r) in mod_dict_names or id(r) in sys_attrs: return 0
487
+ if isinstance(r, type): return 1
488
+ if type(r) is dict: return 2
489
+ return 3
490
+ refs.sort(key=_rank)
491
+ nxt = refs[0]
492
+ visited.add(id(nxt))
493
+ if type(nxt) is dict:
494
+ ks = [k for k, v in list(nxt.items())[:4000] if v is cur][:2]
495
+ if id(nxt) in mod_dict_names:
496
+ chain.append(f"module {mod_dict_names[id(nxt)]} globals {ks}")
497
+ break
498
+ chain.append(f"dict[{ks}] (of {len(nxt)})")
499
+ elif type(nxt) is types.FrameType:
500
+ chain.append(f"frame {nxt.f_globals.get('__name__')}:{nxt.f_code.co_name}:{nxt.f_lineno}")
501
+ break
502
+ elif isinstance(nxt, type):
503
+ slot = [k for k, v in vars(nxt).items() if v is cur][:2]
504
+ chain.append(f"class {nxt.__module__}.{nxt.__qualname__} attrs={slot}")
505
+ break
506
+ elif type(nxt) in (list, tuple, set, frozenset):
507
+ chain.append(f"{type(nxt).__name__}[{len(nxt)}]")
508
+ elif type(nxt) is types.CellType:
509
+ chain.append("cell")
510
+ elif type(nxt) is types.FunctionType:
511
+ chain.append(f"function {nxt.__module__}.{nxt.__qualname__}")
512
+ elif type(nxt) is types.MethodType:
513
+ chain.append(f"bound method {type(nxt.__self__).__name__}.{nxt.__func__.__qualname__}")
514
+ else:
515
+ slot = []
516
+ try:
517
+ slot = [k for k, v in vars(nxt).items() if v is cur][:2]
518
+ except Exception:
519
+ pass
520
+ chain.append(f"{_type_name(nxt)} attrs={slot}")
521
+ cur = nxt
522
+ lines.append(" " + " -> ".join(chain))
523
+ return lines
524
+
525
+
526
+ def _collect(label, live_graph=False, reason=""):
527
+ """gc.collect() plus a REPORT of what it reclaimed — written to one
528
+ file per collect (`_write_report`, under Toggles.GC.report_dir) and
529
+ summarized in a "lag"-column toast whose click opens that file in the
530
+ code editor (the screenshot toast's convention). DEBUG_SAVEALL parks
531
+ every reclaimed cyclic object in gc.garbage so it can be histogrammed by
532
+ type (with a few reprs per top type), grouped one level finer
533
+ (`_detail_rows`: which dicts, which functions, which frames) and by
534
+ module, then released. `reason` is the scheduler's one-line "why now".
535
+
536
+ Toggles.GC.reports off → identical to a bare gc.collect() (no toast).
537
+ Toggles.memory_profile additionally walks the LIVE graph on the boot
538
+ pass (live_graph=True — the graph about to be frozen, and the boot
539
+ collect's price) and appends everything to PROFILE_LOG."""
540
+ import threading
541
+ from meltygui.core.runtime.toggles import Toggles
542
+ profile = _profile_enabled()
543
+ if not profile and (not Toggles.GC.reports or live_graph):
544
+ # The boot pass (live_graph) walks EVERYTHING tracked; SAVEALL
545
+ # would need a SECOND full walk to report what it reclaimed (observed:
546
+ # 5.4 s doubled to 10 s). Bare collect, timed, toast only.
547
+ t0 = time.perf_counter()
548
+ n = gc.collect()
549
+ ms = 1000 * (time.perf_counter() - t0)
550
+ tint = (1.0, 0.25, 0.2) if ms >= 300 else (1.0, 0.65, 0.2)
551
+ notify(f"gc: {label} {ms:.0f}ms [{threading.current_thread().name}] "
552
+ f"{n:,} unreachable — {reason or ''}",
553
+ tint=tint, tag="lag", stack=capture_stack())
554
+ return n
555
+ stamp = time.strftime("%H:%M:%S")
556
+ thread = threading.current_thread().name
557
+ lines = [f"===== {stamp} gc: {label} [{thread}]",
558
+ f"reason: {reason or '-'}",
559
+ f"gen counts={gc.get_count()} thresholds={gc.get_threshold()} "
560
+ f"frozen={gc.get_freeze_count()}"]
561
+ if live_graph and profile:
562
+ t0 = time.perf_counter()
563
+ live = gc.get_objects()
564
+ hist, total = _histogram(live, top=40)
565
+ samples = _samples(live, {tn for tn, _ in hist[:6]})
566
+ lines.append(f"--- LIVE graph before freeze: {total} tracked objects "
567
+ f"(walk {1000*(time.perf_counter()-t0):.0f}ms)")
568
+ for tn, n in hist:
569
+ lines.append(f" {n:>10} {tn}")
570
+ for tn, reprs in samples.items():
571
+ for r in reprs:
572
+ lines.append(f" e.g. {tn}: {r}")
573
+ del samples
574
+ lines.extend(_thread_report())
575
+ try:
576
+ lines.extend(_stale_world_report(live))
577
+ except Exception as e:
578
+ lines.append(f"--- STALE WORLD report failed: {e!r}")
579
+ del live
580
+ gc.set_debug(gc.DEBUG_SAVEALL)
581
+ t0 = time.perf_counter()
582
+ try:
583
+ n = gc.collect()
584
+ finally:
585
+ gc.set_debug(0)
586
+ ms = 1000 * (time.perf_counter() - t0)
587
+ garbage = gc.garbage
588
+ hist, total = _histogram(garbage, top=40)
589
+ samples = _samples(garbage, {tn for tn, _ in hist[:8]})
590
+ nbytes = _approx_bytes(garbage)
591
+ lines.append(f"--- CYCLIC garbage reclaimed: collect()={n} gc.garbage={total} "
592
+ f"~{_fmt_bytes(nbytes)} shallow ({ms:.0f}ms)")
593
+ lines.append(f"--- by type (top 40):")
594
+ for tn, cnt in hist:
595
+ lines.append(f" {cnt:>10} {tn}")
596
+ for tn, reprs in samples.items():
597
+ for r in reprs:
598
+ lines.append(f" e.g. {tn}: {r}")
599
+ del samples
600
+ lines.extend(_module_histogram(garbage))
601
+ lines.extend(_detail_rows(garbage))
602
+ # Release: SAVEALL kept the cycles alive via gc.garbage; dropping the
603
+ # reference leaves them unreachable again, and the follow-up (un-instrumented)
604
+ # collect actually frees them.
605
+ del garbage
606
+ gc.garbage.clear()
607
+ gc.collect()
608
+ if profile:
609
+ _write(lines)
610
+ report = _write_report(label, lines) if Toggles.GC.reports else None
611
+ # Toast: duration first (it is a lag entry), then what went - the top
612
+ # three types by short name. Click → the report file in the editor.
613
+ tops = " · ".join(f"{tn.rsplit('.', 1)[-1]} {cnt:,}" for tn, cnt in hist[:3]) or "nothing"
614
+ tint = (1.0, 0.25, 0.2) if ms >= 300 else (1.0, 0.65, 0.2)
615
+ notify(f"gc: {label} {ms:.0f}ms [{thread}] {total:,} objs ~{_fmt_bytes(nbytes)}: {tops}",
616
+ tint=tint, tag="lag", stack=capture_stack(),
617
+ jump=(str(report), 1) if report else None)
618
+ return n
619
+
620
+
621
+ def _boot_collect_and_freeze(label, trigger=""):
622
+ """The once-per-session full pass: unfreeze → collect → freeze.
623
+
624
+ A studio "restart" is IN-PROCESS (model_server purges src.* from
625
+ sys.modules and re-imports), so this module — and its _state — is fresh
626
+ each session while the interpreter's permanent generation is not:
627
+ everything the PREVIOUS session froze is still parked there, where no
628
+ collect ever looks. Old modules/caches/draw_states are all cyclic, so
629
+ without unfreezing first every restart leaked the whole prior app graph
630
+ for good (observed: 44M frozen vs 1.6M live). unfreeze() moves the
631
+ permanent generation back into gen2 so this one collect reclaims the
632
+ dead prior sessions before we freeze anew.
633
+
634
+ Called from tick() at the first idle window, OR from collect_after_run
635
+ once boot_delay_s has passed: a pre-freeze collect walks the entire graph
636
+ (~900ms on 3M objects) whether or not we freeze after it, so a run that
637
+ lands before the idle window pays the full walk exactly once and freezes
638
+ right there instead of paying it again per run until idle."""
639
+ prev_frozen = gc.get_freeze_count()
640
+ gc.unfreeze()
641
+ _collect(f"{label} collect+freeze", live_graph=True,
642
+ reason=f"boot pass ({trigger or label}): unfreeze → full collect over the "
643
+ f"WHOLE tracked graph → freeze; cost = live graph size, not garbage")
644
+ gc.freeze()
645
+ _state["frozen"] = True
646
+ _state["last_collect"] = time.monotonic()
647
+ notify(f"gc: froze {gc.get_freeze_count()} objects out of gen2 scans"
648
+ f" (unfroze {prev_frozen} from prior sessions first)",
649
+ tint=(0.4, 0.9, 0.4), tag="lag", stack=capture_stack())
650
+
651
+
652
+ def tick():
653
+ """Once per frame from Melty.end_frame (render thread). Cheap when there
654
+ is nothing to do: two attribute reads and a couple of comparisons."""
655
+ from meltygui.core.melty import Melty
656
+ from meltygui.core.runtime.toggles import Toggles
657
+ if not Toggles.GC.manage:
658
+ if _state["applied"]:
659
+ gc.set_threshold(700, 10, 10) # stock CPython defaults
660
+ _state["applied"] = False
661
+ return
662
+ if not _state["applied"]:
663
+ gc.set_threshold(700, 10, int(Toggles.GC.gen2_threshold))
664
+ _state["applied"] = True
665
+ now = time.monotonic()
666
+ if now - _state["boot_t"] < Toggles.GC.boot_delay_s:
667
+ _state["last_tick"] = now
668
+ return
669
+
670
+ # Frames only run on events (the main loop parks in glfw.poll_events), so
671
+ # while the user is away this tick never fires; the last frame BACK saw
672
+ # "idle for ages" and collected right in the user's face. The solution:
673
+ # the focus-LOST edge (nobody is looking - the best possible moment to
674
+ # pay for a collect), a focus-GAIN / long frame gap, which restarts the
675
+ # idle clock so the collect needs idle_seconds of sleep measured from the
676
+ # return, and POINTER PRESENCE (motion inside the window, stamped in the
677
+ # input backend's cursor callback) - a return always starts with the
678
+ # pointer crossing the window, before any click or focus change.
679
+ #
680
+ # The focus edge is POLLED, so it is only trustworthy while frames flow.
681
+ # With frames parked, the first frame back can be the one that reads
682
+ # "unfocused" (pointer over the window, focus not regained yet) against the
683
+ # stale was_focused=True - a loss like that IS the return. So a loss only
684
+ # ARMS the collect; it fires on a later frame (a wake is scheduled) once
685
+ # the window has stayed unfocused, with no presence, for
686
+ # Toggles.GC.unfocus_confirm_s. Any presence or focus meanwhile disarms.
687
+ focused = _window_focused(Melty)
688
+ was_focused = _state["focused"]
689
+ _state["focused"] = focused
690
+ frame_gap = now - _state["last_tick"]
691
+ _state["last_tick"] = now
692
+ if (focused and not was_focused) or frame_gap >= Toggles.GC.idle_seconds:
693
+ _state["resumed_t"] = now
694
+ lost_focus = was_focused and not focused
695
+ last_input = max(getattr(Melty, "_last_input_time", 0.0),
696
+ getattr(Melty, "_last_presence_time", 0.0),
697
+ _state["resumed_t"])
698
+
699
+ pointer_inside = bool(getattr(Melty, "_pointer_inside", True))
700
+ if focused:
701
+ _state["unfocus_armed_t"] = 0.0
702
+ else:
703
+ if lost_focus:
704
+ _state["unfocus_armed_t"] = now
705
+ _wake_in(Toggles.GC.unfocus_confirm_s + 0.05)
706
+ armed_t = _state["unfocus_armed_t"]
707
+ if armed_t and now - armed_t >= Toggles.GC.unfocus_confirm_s:
708
+ _state["unfocus_armed_t"] = 0.0
709
+ # "Nobody is looking" = no presence was input since the arm
710
+ # (the pointer motion that WOKE the arming frame is stamped just
711
+ # before it, hence the margin) AND the pointer is off the window
712
+ # - unfocused with the pointer inside it is a return in progress
713
+ # or someone reading; the pass would land in their face.
714
+ quiet = last_input < armed_t - Toggles.GC.unfocus_confirm_s
715
+ if quiet and not pointer_inside:
716
+ if not _state["frozen"]:
717
+ _boot_collect_and_freeze(
718
+ "boot", trigger=f"window unfocused for {now - armed_t:.1f}s")
719
+ elif now - _state["last_collect"] >= Toggles.GC.unfocus_collect_s:
720
+ _collect("unfocus collect",
721
+ reason=f"window unfocused for {now - armed_t:.1f}s, "
722
+ f"last presence {now - last_input:.0f}s ago, "
723
+ f"last collect {now - _state['last_collect']:.0f}s ago")
724
+ _state["last_collect"] = now
725
+ return
726
+ if now - last_input < Toggles.GC.idle_seconds:
727
+ return
728
+ if not _state["frozen"]:
729
+ # The boot pass walks the WHOLE heap (seconds) and a 15 s pause in
730
+ # typing is not a real pause; the unfocused branch above is the
731
+ # best moment, and idle must be a long one.
732
+ if now - last_input < Toggles.GC.boot_idle_seconds:
733
+ return
734
+ _boot_collect_and_freeze(
735
+ "boot", trigger=f"no input / presence for {now - last_input:.0f}s")
736
+ elif now - _state["last_collect"] >= Toggles.GC.idle_collect_s:
737
+ _collect("idle collect",
738
+ reason=f"no input / presence for {now - last_input:.0f}s "
739
+ f"(idle_seconds={Toggles.GC.idle_seconds:g}), "
740
+ f"last collect {now - _state['last_collect']:.0f}s ago")
741
+ _state["last_collect"] = now
742
+
743
+
744
+ def _wake_in(delay_s):
745
+ """Produce a frame `delay_s` from now (the loop parks in wait_events, so
746
+ the confirm tick above needs a wake to happen at all)."""
747
+ import threading
748
+
749
+ def _fire():
750
+ try:
751
+ from meltygui.core.windowing.glfw_utils import request_render
752
+ request_render()
753
+ except Exception:
754
+ pass
755
+
756
+ t = threading.Timer(max(0.0, float(delay_s)), _fire)
757
+ t.daemon = True
758
+ t.start()
759
+
760
+
761
+ def _window_focused(Melty) -> bool:
762
+ """glfw FOCUSED of the studio window; True when there is no window yet
763
+ (tests / headless) so the idle path behaves as before."""
764
+ window = getattr(Melty, "glfw_window", None)
765
+ if window is None:
766
+ return True
767
+ try:
768
+ import meltygui.core.windowing.window_api as glfw
769
+ return bool(glfw.get_window_attrib(window, glfw.FOCUSED))
770
+ except Exception:
771
+ return True
772
+
773
+
774
+ def collect_after_run(label="run"):
775
+ """One full collect + CUDA cache release, called from a WORKER thread
776
+ right after a heavy run retires its previous generation (the live lab's
777
+ instrumented runs — live_instrument.run_instrumented). gen2's
778
+ auto-trigger is pushed out of reach and the idle collector above waits
779
+ for a quiet input window, but a run's cyclic garbage pins GPU tensors
780
+ (deepcopied component trees, the prior ForwardPassResult graph), and
781
+ VRAM can't wait minutes for idleness while the user is actively
782
+ iterating — observed as ~a full activation generation leaked per Run.
783
+ Post-freeze the pass only walks objects allocated since boot-freeze
784
+ (same price the idle collect pays), scheduled at the one moment it is
785
+ guaranteed profitable; the lag column keeps the cost visible.
786
+
787
+ RATE-LIMITED (Toggles.GC.post_run_min_s): Auto Execute fires a run per
788
+ param-drag tick, and a full collect per tick was a continuous ~120ms
789
+ stall. Runs inside the spacing window coalesce onto a trailing one-shot
790
+ timer that calls back here once the burst rests — so the LAST run's
791
+ garbage still retires promptly (that's the VRAM that matters), while a
792
+ burst pays at most one collect per window."""
793
+ from meltygui.core.runtime.toggles import Toggles
794
+ import threading
795
+ min_s = float(Toggles.GC.post_run_min_s or 0.0)
796
+ now = time.monotonic()
797
+ since = now - _state.get("last_post_run", 0.0)
798
+ # The CUDA cache release is NOT the expensive part (that's the heap
799
+ # walk), but it is what nvidia-smi actually sees: everything the run
800
+ # retired by refcount alone (no longer retain the live lab's per-run
801
+ # activations once their parents re-render) sit in torch's allocator
802
+ # cache until empty_cache. Release on its own short cadence so VRAM
803
+ # tracks the live set while typing, independent of the collect spacing.
804
+ rel_s = float(Toggles.GC.post_run_cache_release_s or 0.0)
805
+ rel_since = now - _state.get("last_cache_release", 0.0)
806
+ if rel_since >= rel_s:
807
+ _state["last_cache_release"] = now
808
+ _release_cuda_cache()
809
+ else:
810
+ # Inside the spacing window: DEFER, never skip - a typing burst's
811
+ # last run must still hand its freed blocks back once it rests.
812
+ release_cuda_cache_soon(rel_s - rel_since, label=label)
813
+ if min_s > 0.0 and since < min_s:
814
+ # Too soon - arm/replace the trailing timer timer. The timer re-enters
815
+ # this function; by then either the window has passed (collect) or
816
+ # newer runs re-armed a fresh timer (coalesce again).
817
+ prev = _state.get("post_run_timer")
818
+ if prev is not None:
819
+ prev.cancel()
820
+ t = threading.Timer(min_s - since, collect_after_run, args=(label,))
821
+ t.daemon = True
822
+ _state["post_run_timer"] = t
823
+ t.start()
824
+ return
825
+ _state["last_post_run"] = now
826
+ if not _state["frozen"] and now - _state["boot_t"] >= Toggles.GC.boot_delay_s:
827
+ # Not frozen yet - this collect walks everything anyway; make it THE
828
+ # boot pass so future runs (and idle) get the cheap post-freeze walk.
829
+ _boot_collect_and_freeze(f"post-{label}", trigger=f"run '{label}' before the idle boot pass")
830
+ _release_cuda_cache()
831
+ return
832
+ _collect(f"post-{label} collect", reason=f"run '{label}' retired its previous generation")
833
+ _release_cuda_cache()
834
+ _state["last_collect"] = time.monotonic()
835
+
836
+
837
+ def release_cuda_cache_soon(delay_s=0.5, label="release"):
838
+ """torch.cuda.empty_cache() shortly, OFF the render thread, coalesced: a
839
+ burst of releases (closing several live views, a prune sweep, runs inside
840
+ the post_run_cache_release_s window) pays one call. Freed tensors only
841
+ leave the allocator's cache — and nvidia-smi / the studio's VRAM readout
842
+ — on empty_cache, and a close has no run behind it to trigger
843
+ collect_after_run's release."""
844
+ import threading
845
+ prev = _state.get("cache_release_timer")
846
+ if prev is not None:
847
+ prev.cancel()
848
+
849
+ def _fire():
850
+ _state["cache_release_timer"] = None
851
+ _state["last_cache_release"] = time.monotonic()
852
+ _release_cuda_cache()
853
+
854
+ t = threading.Timer(max(0.0, float(delay_s)), _fire)
855
+ t.daemon = True
856
+ _state["cache_release_timer"] = t
857
+ t.start()
858
+
859
+
860
+ def _release_cuda_cache():
861
+ try:
862
+ import torch
863
+ if torch.cuda.is_available():
864
+ torch.cuda.empty_cache()
865
+ except Exception:
866
+ pass
867
+
868
+
869
+ # ── CUDA out-of-memory response ─────────────────────────────────────────────
870
+ # After an OOM the live set IS the VRAM: a partial run's accumulator stacks,
871
+ # every live-view window's pinned generation, the runners' previous results,
872
+ # plus whatever the failed run's traceback cycles hold - and nothing retires
873
+ # any of it (the idle/post-run collects are gated or deferred, and
874
+ # empty_cache can't nothing anything is still referenced). Left alone, a
875
+ # later run OOMs too and the only way out was a full reset. The responder
876
+ # dumps out of state deliberately, runs a REAL gc.collect (the exception →
877
+ # traceback → frame cycles are exactly what pins the failed generation), and
878
+ # empties the CUDA cache on every device, then retries what came back.
879
+ #
880
+ # Modules that hold big live state register a releaser here (called with no
881
+ # args, any thread, must not raise) - draw_function's runner threads do.
882
+ OOM_RELEASE_HOOKS = globals().get("OOM_RELEASE_HOOKS") or []
883
+
884
+
885
+ def is_cuda_oom(exc):
886
+ """True for a CUDA allocation failure however it surfaced: torch's
887
+ OutOfMemoryError, the runtime-API 'CUDA error: out of memory'
888
+ RuntimeError (e.g. from a custom kernel's context), pycuda's
889
+ MemoryError, or a GL/CUDA interop refusal carrying the same words."""
890
+ if exc is None:
891
+ return False
892
+ try:
893
+ import torch
894
+ if isinstance(exc, torch.cuda.OutOfMemoryError):
895
+ return True
896
+ except Exception:
897
+ pass
898
+ name = type(exc).__name__
899
+ msg = str(exc).lower()
900
+ if name == "MemoryError" and type(exc).__module__.startswith("pycuda"):
901
+ return True
902
+ return "out of memory" in msg and ("cuda" in msg or "cublas" in msg
903
+ or name == "OutOfMemoryError")
904
+
905
+
906
+ def _device_mem():
907
+ try:
908
+ import torch
909
+ if not torch.cuda.is_available():
910
+ return {}
911
+ return {i: (torch.cuda.memory_allocated(i), torch.cuda.memory_reserved(i))
912
+ for i in range(torch.cuda.device_count())}
913
+ except Exception:
914
+ return {}
915
+
916
+
917
+ _REPORT_SKIP = ("_describe_holder", "report_vram_holders", "_oom_cleanup", "_dict_slot")
918
+
919
+
920
+ def _dict_slot(d, obj):
921
+ for k, v in list(d.items()):
922
+ if v is obj:
923
+ return k
924
+ return None
925
+
926
+
927
+ def _describe_holder(obj, depth, seen, lines, prefix, internal):
928
+ """One line per referrer of `obj` (up to `depth` hops), naming what kind
929
+ of container holds it and, where cheap, WHICH slot: dict key, attribute
930
+ name on an instance, frame code name, module/function name. No
931
+ closures in here — a genexpr capturing `obj` would show up as a cell."""
932
+ if depth <= 0 or id(obj) in seen:
933
+ return
934
+ seen.add(id(obj))
935
+ import types
936
+ try:
937
+ refs = gc.get_referrers(obj)
938
+ except Exception:
939
+ return
940
+ internal.add(id(refs))
941
+ shown = 0
942
+ for r in refs:
943
+ if id(r) in internal or r is lines or r is seen:
944
+ continue
945
+ if isinstance(r, types.FrameType) and r.f_code.co_name in _REPORT_SKIP:
946
+ continue
947
+ tn = type(r).__name__
948
+ if isinstance(r, dict):
949
+ key = _dict_slot(r, obj)
950
+ owner = None
951
+ for rr in gc.get_referrers(r):
952
+ if getattr(rr, "__dict__", None) is r:
953
+ owner = rr
954
+ break
955
+ if isinstance(rr, dict):
956
+ nm = _dict_slot(rr, r)
957
+ if nm is not None:
958
+ owner = f"dict[{nm!r}]"
959
+ break
960
+ if owner is not None and not isinstance(owner, str):
961
+ oname = (getattr(owner, "__qualname__", None) or getattr(owner, "__name__", None)
962
+ or getattr(owner, "name", None) or type(owner).__name__)
963
+ label = f"attr {key!r} of {type(owner).__name__} {oname}"
964
+ else:
965
+ label = f"dict[{key!r}]" + (f" in {owner}" if owner else "")
966
+ elif isinstance(r, (list, tuple, set, frozenset)):
967
+ label = f"{tn}[{len(r)}]"
968
+ elif isinstance(r, types.FrameType):
969
+ label = (f"frame {r.f_code.co_name} "
970
+ f"({r.f_code.co_filename.rsplit('/', 1)[-1]}:{r.f_lineno})")
971
+ elif isinstance(r, types.CellType):
972
+ label = "closure cell"
973
+ else:
974
+ slot = None
975
+ for a in getattr(r, "__slots__", ()):
976
+ if getattr(r, a, None) is obj:
977
+ slot = a
978
+ break
979
+ if slot is None:
980
+ try: # 3.12 inline class dicts: the instance IS the referrer
981
+ slot = _dict_slot(vars(r), obj)
982
+ except TypeError:
983
+ pass
984
+ label = tn + (f".{slot}" if slot else "")
985
+ qn = getattr(r, "__qualname__", None)
986
+ if isinstance(qn, str):
987
+ label += f" {qn}"
988
+ lines.append(f"{prefix}<- {label}")
989
+ shown += 1
990
+ if shown >= 4:
991
+ lines.append(f"{prefix} (+{len(refs) - shown} more referrers)")
992
+ break
993
+ if not isinstance(r, types.FrameType):
994
+ _describe_holder(r, depth - 1, seen, lines, prefix + " ", internal)
995
+
996
+
997
+ def report_vram_holders(top=12, device=None, depth=3):
998
+ """Who holds the VRAM: every CUDA tensor reachable from gc, aggregated
999
+ by STORAGE (views share one), the top-N storages by bytes with the
1000
+ referrer chain of one tensor over each. Unfreezes the permanent
1001
+ generation for the walk (gc.get_objects skips it) and re-freezes.
1002
+ Parameters show up too — the model's weights are part of the answer.
1003
+ Returns the report lines (also printed). Seconds, OOM-time only."""
1004
+ t0 = time.perf_counter()
1005
+ # Unfrozen for the WHOLE report: neither get_objects nor get_referrers
1006
+ # looks through the permanent generation. Deliberately NOT re-frozen
1007
+ # here: gc.freeze() freezes EVERYTHING tracked - including whatever
1008
+ # cyclic garbage is pending - and a frozen cycle can never be collected
1009
+ # (an earlier version did this and pinned 28 retired generations). The
1010
+ # OOM handler re-freezes after its collect (the boot regime); a manual
1011
+ # call leaves the heap unfrozen, which only makes the next collect walk
1012
+ # more.
1013
+ gc.unfreeze()
1014
+ return _report_vram_holders(top, device, depth, t0)
1015
+
1016
+
1017
+ def _report_vram_holders(top, device, depth, t0):
1018
+ objs = gc.get_objects()
1019
+ by_storage = {}
1020
+ n_tensors = 0
1021
+ for o in objs:
1022
+ if type(o).__name__ not in ("Tensor", "Parameter"):
1023
+ continue
1024
+ try:
1025
+ if not o.is_cuda or (device is not None and (o.device.index or 0) != device):
1026
+ continue
1027
+ st = o.untyped_storage()
1028
+ key = (st.data_ptr(), o.device.index or 0)
1029
+ nbytes = st.nbytes()
1030
+ except Exception:
1031
+ continue
1032
+ n_tensors += 1
1033
+ ent = by_storage.get(key)
1034
+ if ent is None:
1035
+ by_storage[key] = [nbytes, o.device.index or 0, [o]]
1036
+ elif len(ent[2]) < 4:
1037
+ ent[2].append(o) # the full tensor AND its views: a VIEW held
1038
+ # by a draw call holds the storage just as well
1039
+ del objs, o
1040
+ total = 0
1041
+ per_dev = {}
1042
+ for nb, d, _t in by_storage.values():
1043
+ total += nb
1044
+ per_dev[d] = per_dev.get(d, 0) + nb
1045
+ del _t
1046
+ ranked = sorted(by_storage.values(), key=lambda e: -e[0])
1047
+ # Representatives in ONE flat list the describer knows to skip; the
1048
+ # bookkeeping containers above are released before any referrer walk.
1049
+ reps = []
1050
+ for e in ranked[:top]:
1051
+ reps.append((e[0], e[1], tuple(e[2])))
1052
+ small = 0
1053
+ for e in ranked[top:]:
1054
+ small += e[0]
1055
+ n_storages = len(by_storage)
1056
+ del by_storage, ranked, e, ent
1057
+ lines = [f"=== VRAM holders: {n_tensors} CUDA tensors over {n_storages} storages, "
1058
+ f"{total/2**30:.1f} GB reachable (walk {time.perf_counter()-t0:.1f}s)",
1059
+ " per device: " + ", ".join(f"cuda:{d} {v/2**30:.1f} GB"
1060
+ for d, v in sorted(per_dev.items()))]
1061
+ seen = set()
1062
+ internal = {id(reps), id(per_dev)}
1063
+ for e in reps:
1064
+ internal.add(id(e))
1065
+ internal.add(id(e[2]))
1066
+ del e
1067
+ for i in range(len(reps)):
1068
+ nb, d, ts = reps[i]
1069
+ lines.append(f"- {nb/2**30:6.2f} GB cuda:{d} storage, {len(ts)} tensor(s) over it:")
1070
+ for t in ts:
1071
+ lines.append(f" {type(t).__name__}{tuple(t.shape)} "
1072
+ f"{str(t.dtype).replace('torch.', '')}"
1073
+ f"{' (view)' if t.numel() * t.element_size() < nb else ''}")
1074
+ _describe_holder(t, depth, seen, lines, " ", internal)
1075
+ del t, ts
1076
+ lines.append(f" (+{small/2**30:.1f} GB in {max(0, n_storages - top)} smaller storages)")
1077
+ text = "\n".join(lines)
1078
+ print(text)
1079
+ return lines
1080
+
1081
+
1082
+ def _oom_cleanup(where):
1083
+ import threading
1084
+ _state["oom_timer"] = None
1085
+ before = _device_mem()
1086
+ if Toggles.GC.oom_holder_report:
1087
+ try:
1088
+ report_vram_holders()
1089
+ except Exception as e:
1090
+ print(f"[gc] oom: holder report failed: {e!r}")
1091
+ dropped = 0
1092
+ try:
1093
+ from meltygui.code.live_view import release_all_live_stores
1094
+ dropped = release_all_live_stores(discover=True)
1095
+ except Exception as e:
1096
+ print(f"[gc] oom: release_all_live_stores failed: {e!r}")
1097
+ for hook in list(OOM_RELEASE_HOOKS):
1098
+ try:
1099
+ hook()
1100
+ except Exception as e:
1101
+ print(f"[gc] oom: release hook {getattr(hook, '__name__', hook)} failed: {e!r}")
1102
+ # A real collect, regardless of the profiler/idle gating: the failed
1103
+ # run's pending cycles are the generation that must die. Unfreeze
1104
+ # first (prior runs / an earlier report may have frozen garbage),
1105
+ # re-freeze what remains for the boot regime: later collects only walk
1106
+ # what's new.
1107
+ t0 = time.perf_counter()
1108
+ try:
1109
+ gc.unfreeze()
1110
+ n = gc.collect()
1111
+ gc.freeze()
1112
+ except Exception:
1113
+ n = -1
1114
+ _release_cuda_cache()
1115
+ try:
1116
+ import torch
1117
+ if torch.cuda.is_available():
1118
+ for i in range(torch.cuda.device_count()):
1119
+ with torch.cuda.device(i):
1120
+ torch.cuda.empty_cache()
1121
+ except Exception:
1122
+ pass
1123
+ after = _device_mem()
1124
+ parts = []
1125
+ for i in sorted(set(before) | set(after)):
1126
+ ba, br = before.get(i, (0, 0))
1127
+ aa, ar = after.get(i, (0, 0))
1128
+ parts.append(f"cuda:{i} reserved {br/2**30:.1f}→{ar/2**30:.1f} GB "
1129
+ f"(allocated {ba/2**30:.1f}→{aa/2**30:.1f})")
1130
+ msg = (f"CUDA OOM in {where}: released {dropped} live keys, "
1131
+ f"gc {n} objs in {1000*(time.perf_counter()-t0):.0f}ms; "
1132
+ + "; ".join(parts))
1133
+ print(f"[gc] {msg}")
1134
+ try:
1135
+ notify(msg, tint=(1.0, 0.55, 0.3), tag="oom")
1136
+ except Exception:
1137
+ pass
1138
+ try:
1139
+ from meltygui.core.windowing.glfw_utils import request_render
1140
+ request_render()
1141
+ except Exception:
1142
+ pass
1143
+ _state["last_post_run"] = _state["last_collect"] = time.monotonic()
1144
+
1145
+
1146
+ def respond_to_cuda_oom(exc=None, where="run", delay_s=0.25):
1147
+ """Call from an except block that caught `exc` (or with exc=None to
1148
+ force). No-op unless is_cuda_oom(exc). The cleanup itself is DEFERRED
1149
+ onto a short timer thread — it must run after the raising frames have
1150
+ unwound (while the handler runs, the traceback still pins the failed
1151
+ run's tensors, and a collect there frees nothing) — and coalesced, so a
1152
+ burst of failures pays one sweep. Returns whether a cleanup was armed."""
1153
+ import threading
1154
+ if exc is not None and not is_cuda_oom(exc):
1155
+ return False
1156
+ prev = _state.get("oom_timer")
1157
+ if prev is not None:
1158
+ prev.cancel()
1159
+ t = threading.Timer(max(0.0, float(delay_s)), _oom_cleanup, args=(where,))
1160
+ t.daemon = True
1161
+ _state["oom_timer"] = t
1162
+ t.start()
1163
+ return True