railmux 0.4.0.dev29__tar.gz → 0.4.0.dev31__tar.gz

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 (183) hide show
  1. {railmux-0.4.0.dev29/src/railmux.egg-info → railmux-0.4.0.dev31}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev31/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/cli.py +27 -1
  4. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_install_log.py +13 -6
  5. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_msys2.py +38 -9
  6. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_pacman.py +24 -3
  7. railmux-0.4.0.dev31/src/railmux/windows_paths.py +73 -0
  8. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31/src/railmux.egg-info}/PKG-INFO +1 -1
  9. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux.egg-info/SOURCES.txt +2 -0
  10. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_cli.py +75 -0
  11. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_install_log.py +20 -0
  12. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_msys2.py +67 -0
  13. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_pacman.py +35 -0
  14. railmux-0.4.0.dev31/tests/test_windows_paths.py +83 -0
  15. railmux-0.4.0.dev29/src/railmux/__init__.py +0 -1
  16. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/LICENSE +0 -0
  17. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/MANIFEST.in +0 -0
  18. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/README.md +0 -0
  19. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/pyproject.toml +0 -0
  20. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/setup.cfg +0 -0
  21. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/__main__.py +0 -0
  22. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/atomic_file.py +0 -0
  23. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/background_index.py +0 -0
  24. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/codex_index.py +0 -0
  25. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/config.py +0 -0
  26. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/config_cli.py +0 -0
  27. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/diagnostics.py +0 -0
  28. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/discovery.py +0 -0
  29. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/display_transport.py +0 -0
  30. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/entrypoint.py +0 -0
  31. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/fast_display_client.py +0 -0
  32. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/fast_display_history.py +0 -0
  33. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/fast_display_input.py +0 -0
  34. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/fast_display_protocol.py +0 -0
  35. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/fast_display_server.py +0 -0
  36. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/favorites.py +0 -0
  37. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/fuzzy.py +0 -0
  38. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/help_workspace.py +0 -0
  39. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/launcher.py +0 -0
  40. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/legacy_sessions.py +0 -0
  41. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/local_clipboard.py +0 -0
  42. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/local_open.py +0 -0
  43. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/models.py +0 -0
  44. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/modes.py +0 -0
  45. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/mouse_manager.py +0 -0
  46. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/orphan_marker.py +0 -0
  47. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/pane_surface.py +0 -0
  48. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/path_codec.py +0 -0
  49. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/provider_paths.py +0 -0
  50. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/remote_config.py +0 -0
  51. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/renames.py +0 -0
  52. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/restart_state.py +0 -0
  53. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/runtime_config.py +0 -0
  54. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/scroll_agent.py +0 -0
  55. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/scroll_manager.py +0 -0
  56. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/selection_isolation.py +0 -0
  57. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/self_update.py +0 -0
  58. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/session_cache.py +0 -0
  59. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/session_index.py +0 -0
  60. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/session_lease.py +0 -0
  61. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/setting_contracts.py +0 -0
  62. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/settings.py +0 -0
  63. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ssh_args.py +0 -0
  64. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ssh_compat.py +0 -0
  65. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ssh_display_diagnostics.py +0 -0
  66. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ssh_doctor.py +0 -0
  67. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/system_deps.py +0 -0
  68. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/terminal_status.py +0 -0
  69. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/tmux_binding_manager.py +0 -0
  70. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/tmux_ctl.py +0 -0
  71. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/tmux_health.py +0 -0
  72. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/tmux_server.py +0 -0
  73. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/tool_panes.py +0 -0
  74. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/transcript.py +0 -0
  75. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/__init__.py +0 -0
  76. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/_widgets.py +0 -0
  77. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/app.py +0 -0
  78. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/keymap.py +0 -0
  79. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/modals.py +0 -0
  80. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/projects_pane.py +0 -0
  81. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/running_pane.py +0 -0
  82. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/sessions_pane.py +0 -0
  83. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/sidebar.py +0 -0
  84. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/statusbar.py +0 -0
  85. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/ui/workspace.py +0 -0
  86. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_attach_relay.py +0 -0
  87. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_bootstrap.py +0 -0
  88. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_tmux_lifecycle.py +0 -0
  89. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux/windows_ui_transition.py +0 -0
  90. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux.egg-info/dependency_links.txt +0 -0
  91. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux.egg-info/entry_points.txt +0 -0
  92. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux.egg-info/requires.txt +0 -0
  93. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/src/railmux.egg-info/top_level.txt +0 -0
  94. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_agent_workspace.py +0 -0
  95. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_atomic_file.py +0 -0
  96. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_background_index.py +0 -0
  97. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_codex_delete.py +0 -0
  98. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_codex_index.py +0 -0
  99. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_codex_lineage.py +0 -0
  100. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_codex_transcript.py +0 -0
  101. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_config.py +0 -0
  102. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_config_cli.py +0 -0
  103. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_denested_transport_benchmark.py +0 -0
  104. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_diagnostics.py +0 -0
  105. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_discovery.py +0 -0
  106. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_display_transport.py +0 -0
  107. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_fast_full_window.py +0 -0
  108. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_focus_highlight.py +0 -0
  109. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_fullscreen_binding.py +0 -0
  110. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_fuzzy.py +0 -0
  111. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_help_agent.py +0 -0
  112. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_help_workspace.py +0 -0
  113. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_history_cleanup.py +0 -0
  114. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_keymap.py +0 -0
  115. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_launcher.py +0 -0
  116. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_layout_profile.py +0 -0
  117. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_legacy_sessions.py +0 -0
  118. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_local_clipboard.py +0 -0
  119. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_local_open.py +0 -0
  120. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_modals.py +0 -0
  121. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_mode_selection.py +0 -0
  122. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_modes.py +0 -0
  123. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_mouse_manager.py +0 -0
  124. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_new_project.py +0 -0
  125. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_options.py +0 -0
  126. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_orphan_recovery.py +0 -0
  127. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_pane_surface.py +0 -0
  128. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_path_codec.py +0 -0
  129. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_projects_pane.py +0 -0
  130. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_provider_paths.py +0 -0
  131. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_release_notes.py +0 -0
  132. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_release_workflow.py +0 -0
  133. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_remote_config.py +0 -0
  134. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_renames.py +0 -0
  135. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_restart_state.py +0 -0
  136. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_running_pane.py +0 -0
  137. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_running_sort.py +0 -0
  138. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_runtime_config.py +0 -0
  139. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_scroll_agent.py +0 -0
  140. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_scroll_manager.py +0 -0
  141. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_selection_isolation.py +0 -0
  142. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_self_update.py +0 -0
  143. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_session_cache.py +0 -0
  144. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_session_index.py +0 -0
  145. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_session_lease.py +0 -0
  146. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_sessions_pane.py +0 -0
  147. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_setting_contracts.py +0 -0
  148. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_settings.py +0 -0
  149. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_sidebar.py +0 -0
  150. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_soft_quit.py +0 -0
  151. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_ssh_args.py +0 -0
  152. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_ssh_compat.py +0 -0
  153. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_ssh_display_benchmark.py +0 -0
  154. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_ssh_display_diagnostics.py +0 -0
  155. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_ssh_doctor.py +0 -0
  156. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_status.py +0 -0
  157. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_statusbar.py +0 -0
  158. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_system_deps.py +0 -0
  159. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_terminal_size.py +0 -0
  160. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_terminal_status.py +0 -0
  161. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_termux_local_touch.py +0 -0
  162. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tmux_binding_manager.py +0 -0
  163. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tmux_ctl.py +0 -0
  164. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tmux_health.py +0 -0
  165. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tmux_integration.py +0 -0
  166. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tmux_server.py +0 -0
  167. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tmux_status.py +0 -0
  168. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_tool_panes.py +0 -0
  169. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_transcript.py +0 -0
  170. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_utf8_state.py +0 -0
  171. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_web_demo_recorder.py +0 -0
  172. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_wheel_smoke.py +0 -0
  173. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_widgets.py +0 -0
  174. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_attach_relay.py +0 -0
  175. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_bootstrap.py +0 -0
  176. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_tmux_lifecycle.py +0 -0
  177. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_ui_transition.py +0 -0
  178. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_windows_wrapper_contract.py +0 -0
  179. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tests/test_yolo_prompt.py +0 -0
  180. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tools/denested_transport_benchmark.py +0 -0
  181. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tools/release_notes.py +0 -0
  182. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tools/wheel_smoke.py +0 -0
  183. {railmux-0.4.0.dev29 → railmux-0.4.0.dev31}/tools/windows_archive_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev29
3
+ Version: 0.4.0.dev31
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev31"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import os
5
+ import signal
5
6
  import shutil
6
7
  import subprocess
7
8
  import sys
@@ -109,6 +110,9 @@ def _run_tmux_client_with_watchdog(
109
110
  attributes = termios.tcgetattr(sys.stdin.fileno())
110
111
  except (OSError, termios.error):
111
112
  pass
113
+ direct_terminal_size = (
114
+ _interactive_terminal_size() if managed_windows else None
115
+ )
112
116
  started_at = time.monotonic()
113
117
  try:
114
118
  popen_kwargs: dict[str, object] = {"env": env}
@@ -127,7 +131,7 @@ def _run_tmux_client_with_watchdog(
127
131
  *,
128
132
  asynchronous_probe: bool = False,
129
133
  ) -> tuple[int, bool]:
130
- nonlocal expected_target, expected_session_id
134
+ nonlocal direct_terminal_size, expected_target, expected_session_id
131
135
  watchdog = tmux_health.FailureWatchdog.starting(
132
136
  (
133
137
  time.monotonic()
@@ -151,6 +155,28 @@ def _run_tmux_client_with_watchdog(
151
155
  pump = getattr(process, "pump", None)
152
156
  if pump is None:
153
157
  time.sleep(0.25)
158
+ if managed_windows:
159
+ current_size = _interactive_terminal_size()
160
+ if (
161
+ current_size is not None
162
+ and current_size != direct_terminal_size
163
+ ):
164
+ direct_terminal_size = current_size
165
+ pid = getattr(process, "pid", None)
166
+ if (
167
+ isinstance(pid, int)
168
+ and not isinstance(pid, bool)
169
+ and pid > 0
170
+ ):
171
+ try:
172
+ # MSYS2 occasionally misses the native
173
+ # console's resize notification. Signal only
174
+ # this launcher's direct tmux client so it
175
+ # re-reads both dimensions; tmux remains the
176
+ # authority for shared-window sizing.
177
+ os.kill(pid, signal.SIGWINCH)
178
+ except (OSError, ValueError):
179
+ pass
154
180
  else:
155
181
  pump(0.25)
156
182
  now = time.monotonic()
@@ -22,6 +22,7 @@ from railmux.terminal_status import (
22
22
  STYLE_WARNING,
23
23
  styled,
24
24
  )
25
+ from railmux.windows_paths import managed_windows_data_root
25
26
 
26
27
 
27
28
  _LOG_KEEP_COUNT = 5
@@ -79,14 +80,14 @@ def install_log_path(
79
80
  *,
80
81
  version: str,
81
82
  clock: Callable[[], float] = time.time,
83
+ data_root: Path | None = None,
82
84
  ) -> Path:
83
- local_app_data = environ.get("LOCALAPPDATA", "").strip()
84
- if not local_app_data:
85
- raise OSError("LOCALAPPDATA is unavailable")
85
+ root = managed_windows_data_root(environ) if data_root is None else data_root
86
+ if root is None:
87
+ raise OSError("a non-virtualized Windows data directory is unavailable")
86
88
  stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(clock()))
87
89
  return (
88
- Path(local_app_data)
89
- / "Railmux"
90
+ root
90
91
  / "logs"
91
92
  / f"install-{version}-{stamp}-{os.getpid()}.log"
92
93
  )
@@ -323,7 +324,13 @@ class InstallReporter:
323
324
  if package_count is not None:
324
325
  self._package_total = int(package_count.group(1))
325
326
  self._package_step = max(1, self._package_total // 10)
326
- self._progress_detail = f"preparing {self._package_total} packages"
327
+ self._progress_detail = (
328
+ f"waiting for the first transfer among "
329
+ f"{self._package_total} packages"
330
+ )
331
+ self.note(
332
+ f"Package transaction resolved: {self._package_total} packages."
333
+ )
327
334
  return
328
335
  total_download = _TOTAL_DOWNLOAD_RE.match(line)
329
336
  if total_download is not None:
@@ -42,6 +42,10 @@ from railmux.windows_pacman import (
42
42
  validate_transaction_package_mirrors,
43
43
  write_msys_only_pacman_config,
44
44
  )
45
+ from railmux.windows_paths import (
46
+ legacy_local_app_data_root,
47
+ managed_windows_data_root,
48
+ )
45
49
  from railmux.terminal_status import STYLE_ACCENT, STYLE_MUTED, styled
46
50
 
47
51
 
@@ -554,17 +558,17 @@ class Msys2Runtime:
554
558
 
555
559
 
556
560
  def _managed_base(environ: Mapping[str, str]) -> Path | None:
557
- local_app_data = environ.get("LOCALAPPDATA", "").strip()
558
- if not local_app_data:
561
+ data_root = managed_windows_data_root(environ)
562
+ if data_root is None:
559
563
  return None
560
- return Path(local_app_data) / "Railmux" / "runtimes"
564
+ return data_root / "runtimes"
561
565
 
562
566
 
563
567
  def _managed_cache(environ: Mapping[str, str]) -> Path | None:
564
- local_app_data = environ.get("LOCALAPPDATA", "").strip()
565
- if not local_app_data:
568
+ data_root = managed_windows_data_root(environ)
569
+ if data_root is None:
566
570
  return None
567
- return Path(local_app_data) / "Railmux" / "cache"
571
+ return data_root / "cache"
568
572
 
569
573
 
570
574
  def _path_is_link_or_reparse(path: Path) -> bool:
@@ -595,6 +599,7 @@ def _prepare_cached_archive(
595
599
  cache: Path,
596
600
  *,
597
601
  downloader: Downloader | None,
602
+ prior_caches: Sequence[Path] = (),
598
603
  ) -> tuple[Path, str]:
599
604
  cache.mkdir(parents=True, exist_ok=True)
600
605
  archive = cache / MSYS2_ARCHIVE_NAME
@@ -604,6 +609,18 @@ def _prepare_cached_archive(
604
609
  archive.unlink(missing_ok=True)
605
610
  except OSError as exc:
606
611
  raise RuntimeInstallError("could not replace the private archive cache") from exc
612
+ for prior_cache in prior_caches:
613
+ prior_archive = prior_cache / MSYS2_ARCHIVE_NAME
614
+ if prior_archive == archive or not _archive_cache_is_valid(prior_archive):
615
+ continue
616
+ try:
617
+ shutil.copyfile(prior_archive, archive)
618
+ except OSError:
619
+ archive.unlink(missing_ok=True)
620
+ continue
621
+ if _archive_cache_is_valid(archive):
622
+ return archive, "verified prior local cache"
623
+ archive.unlink(missing_ok=True)
607
624
  source = download_from_sources(
608
625
  archive,
609
626
  MSYS2_ARCHIVE_SHA256,
@@ -1178,7 +1195,9 @@ def apply_managed_runtime_prune(
1178
1195
  """Re-plan under the install lock, then remove only unchanged candidates."""
1179
1196
  base = _managed_base(environ)
1180
1197
  if base is None:
1181
- raise RuntimeInstallError("LOCALAPPDATA is unavailable")
1198
+ raise RuntimeInstallError(
1199
+ "a non-virtualized Windows user data directory is unavailable"
1200
+ )
1182
1201
  effective_lock = install_lock if lock_factory is None else lock_factory
1183
1202
  with effective_lock(base):
1184
1203
  current = plan_managed_runtime_prune(
@@ -2629,11 +2648,16 @@ def install_managed_runtime(
2629
2648
  base = _managed_base(environ)
2630
2649
  cache_base = _managed_cache(environ)
2631
2650
  if base is None or cache_base is None:
2632
- raise RuntimeInstallError("LOCALAPPDATA is unavailable")
2651
+ raise RuntimeInstallError(
2652
+ "a non-virtualized Windows user data directory is unavailable"
2653
+ )
2654
+ data_root = base.parent
2633
2655
  try:
2634
2656
  base.mkdir(parents=True, exist_ok=True)
2635
2657
  cache_base.mkdir(parents=True, exist_ok=True)
2636
- log_path = install_log_path(environ, version=version)
2658
+ log_path = install_log_path(
2659
+ environ, version=version, data_root=data_root
2660
+ )
2637
2661
  except OSError as exc:
2638
2662
  raise RuntimeInstallError("could not create the private runtime log") from exc
2639
2663
  final_root = base / "shared" / MSYS2_RUNTIME_ID
@@ -2749,6 +2773,11 @@ def install_managed_runtime(
2749
2773
  archive, source = _prepare_cached_archive(
2750
2774
  cache_base,
2751
2775
  downloader=downloader,
2776
+ prior_caches=tuple(
2777
+ legacy / "cache"
2778
+ for legacy in (legacy_local_app_data_root(environ),)
2779
+ if legacy is not None and legacy != data_root
2780
+ ),
2752
2781
  )
2753
2782
  reporter.done(source)
2754
2783
 
@@ -30,7 +30,9 @@ _MIRRORLIST_LIMIT = 256 * 1024
30
30
  _PACMAN_CONF_LIMIT = 256 * 1024
31
31
  _PROBE_BYTES = 256 * 1024
32
32
  _PROBE_TIMEOUT = 8.0
33
+ _PACKAGE_PROBE_BYTES = 256 * 1024
33
34
  _PACKAGE_PROBE_TIMEOUT = 5.0
35
+ _PACKAGE_PROBE_MIN_RATE = 128 * 1024
34
36
  _SWITCH_RATIO = 1.25
35
37
  _FRESHNESS_WINDOW_SECONDS = 6 * 60 * 60
36
38
  _ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
@@ -91,18 +93,21 @@ def _probe_package_url(
91
93
  url: str,
92
94
  *,
93
95
  opener: Callable[..., object] = urllib.request.urlopen,
96
+ clock: Callable[[], float] = time.monotonic,
94
97
  ) -> None:
95
- """Require one real package prefix instead of trusting database access."""
98
+ """Require a responsive real package path, not only database access."""
96
99
  if urllib.parse.urlsplit(url).scheme.lower() != "https":
97
100
  raise PacmanMirrorError("package URL is not HTTPS")
98
101
  request = urllib.request.Request(
99
102
  url,
100
103
  headers={
101
104
  "Accept-Encoding": "identity",
102
- "Range": "bytes=0-1023",
105
+ "Range": f"bytes=0-{_PACKAGE_PROBE_BYTES - 1}",
103
106
  "User-Agent": "Railmux-pacman-package-probe/1",
104
107
  },
105
108
  )
109
+ started = clock()
110
+ data = bytearray()
106
111
  try:
107
112
  with opener(request, timeout=_PACKAGE_PROBE_TIMEOUT) as response:
108
113
  final_url = getattr(response, "geturl", lambda: request.full_url)()
@@ -111,15 +116,31 @@ def _probe_package_url(
111
116
  status = getattr(response, "status", 200)
112
117
  if status not in (200, 206):
113
118
  raise PacmanMirrorError(f"package returned HTTP {status}")
114
- data = response.read(1024)
119
+ while len(data) < _PACKAGE_PROBE_BYTES:
120
+ chunk = response.read(min(
121
+ 64 * 1024, _PACKAGE_PROBE_BYTES - len(data)
122
+ ))
123
+ if not chunk:
124
+ break
125
+ data.extend(chunk)
126
+ if clock() - started >= _PACKAGE_PROBE_TIMEOUT:
127
+ break
115
128
  except PacmanMirrorError:
116
129
  raise
117
130
  except urllib.error.HTTPError as exc:
118
131
  raise PacmanMirrorError(f"package returned HTTP {exc.code}") from exc
119
132
  except (OSError, urllib.error.URLError, http.client.HTTPException) as exc:
120
133
  raise PacmanMirrorError("package probe failed") from exc
134
+ elapsed = max(clock() - started, 0.001)
135
+ if len(data) < 1024:
136
+ raise PacmanMirrorError("mirror returned too little package data")
121
137
  if not data.startswith(_ZSTD_MAGIC):
122
138
  raise PacmanMirrorError("mirror did not return an MSYS package")
139
+ if (
140
+ elapsed >= _PACKAGE_PROBE_TIMEOUT
141
+ or len(data) / elapsed < _PACKAGE_PROBE_MIN_RATE
142
+ ):
143
+ raise PacmanMirrorError("package transfer was too slow")
123
144
 
124
145
 
125
146
  def probe_pacman_mirror(
@@ -0,0 +1,73 @@
1
+ """Stable native-Windows storage paths for the managed runtime.
2
+
3
+ Microsoft Store and other MSIX-packaged Python interpreters can virtualize new
4
+ writes beneath AppData. Railmux's runtime contains executables that must be
5
+ opened by child processes outside that virtualized view, so packaged Python
6
+ uses a user-profile directory instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import os
13
+ import sys
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+
18
+ _ERROR_INSUFFICIENT_BUFFER = 122
19
+ _APPMODEL_ERROR_NO_PACKAGE = 15700
20
+ _PACKAGED_PROFILE_DIRECTORY = ".railmux"
21
+ _PACKAGED_WINDOWS_DIRECTORY = "windows"
22
+
23
+
24
+ def _has_windows_package_identity() -> bool:
25
+ """Return whether this process has an MSIX/AppX package identity."""
26
+ if os.name != "nt":
27
+ return False
28
+ try:
29
+ get_family = ctypes.windll.kernel32.GetCurrentPackageFamilyName
30
+ length = ctypes.c_uint32(0)
31
+ result = int(get_family(ctypes.byref(length), None))
32
+ except (AttributeError, OSError, TypeError, ValueError):
33
+ return _looks_like_packaged_executable(sys.executable)
34
+ if result == _APPMODEL_ERROR_NO_PACKAGE:
35
+ return False
36
+ if result == _ERROR_INSUFFICIENT_BUFFER and length.value > 0:
37
+ return True
38
+ # A Store Python launcher and interpreter both have characteristic physical
39
+ # paths. Keep this fallback fail-safe if the package API is unavailable or
40
+ # returns an unexpected platform error.
41
+ return _looks_like_packaged_executable(sys.executable)
42
+
43
+
44
+ def _looks_like_packaged_executable(executable: str) -> bool:
45
+ normalized = executable.replace("/", "\\").casefold()
46
+ return "\\windowsapps\\" in normalized or (
47
+ "\\appdata\\local\\packages\\" in normalized and "\\localcache\\" in normalized
48
+ )
49
+
50
+
51
+ def managed_windows_data_root(environ: Mapping[str, str]) -> Path | None:
52
+ """Select one data root visible to both Python and MSYS2 child processes."""
53
+ if _has_windows_package_identity():
54
+ user_profile = environ.get("USERPROFILE", "").strip()
55
+ if not user_profile:
56
+ return None
57
+ return (
58
+ Path(user_profile)
59
+ / _PACKAGED_PROFILE_DIRECTORY
60
+ / _PACKAGED_WINDOWS_DIRECTORY
61
+ )
62
+ local_app_data = environ.get("LOCALAPPDATA", "").strip()
63
+ if not local_app_data:
64
+ return None
65
+ return Path(local_app_data) / "Railmux"
66
+
67
+
68
+ def legacy_local_app_data_root(environ: Mapping[str, str]) -> Path | None:
69
+ """Return the pre-packaged-Python-fix location for read-only cache reuse."""
70
+ local_app_data = environ.get("LOCALAPPDATA", "").strip()
71
+ if not local_app_data:
72
+ return None
73
+ return Path(local_app_data) / "Railmux"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev29
3
+ Version: 0.4.0.dev31
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -63,6 +63,7 @@ src/railmux/windows_bootstrap.py
63
63
  src/railmux/windows_install_log.py
64
64
  src/railmux/windows_msys2.py
65
65
  src/railmux/windows_pacman.py
66
+ src/railmux/windows_paths.py
66
67
  src/railmux/windows_tmux_lifecycle.py
67
68
  src/railmux/windows_ui_transition.py
68
69
  src/railmux.egg-info/PKG-INFO
@@ -168,6 +169,7 @@ tests/test_windows_bootstrap.py
168
169
  tests/test_windows_install_log.py
169
170
  tests/test_windows_msys2.py
170
171
  tests/test_windows_pacman.py
172
+ tests/test_windows_paths.py
171
173
  tests/test_windows_tmux_lifecycle.py
172
174
  tests/test_windows_ui_transition.py
173
175
  tests/test_windows_wrapper_contract.py
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import signal
2
3
  import subprocess
3
4
  import sys
4
5
  from unittest.mock import MagicMock, patch
@@ -632,6 +633,80 @@ def test_local_tmux_watchdog_exits_and_records_after_consecutive_failures(
632
633
  assert "stopped responding" in capsys.readouterr().err
633
634
 
634
635
 
636
+ def test_managed_windows_direct_attach_notifies_height_only_resize(monkeypatch):
637
+ target = TmuxServerTarget("/tmp/railmux", 77)
638
+
639
+ class DirectClient:
640
+ pid = 321
641
+ returncode = None
642
+
643
+ def poll(self):
644
+ return self.returncode
645
+
646
+ client = DirectClient()
647
+ monkeypatch.setattr(
648
+ "railmux.cli.subprocess.Popen", lambda *_args, **_kwargs: client)
649
+ monkeypatch.setattr("railmux.cli.sys.stdin.isatty", lambda: True)
650
+ monkeypatch.setattr("railmux.cli.sys.stdout.isatty", lambda: True)
651
+ monkeypatch.setattr("railmux.cli.termios.tcgetattr", lambda _fd: ["saved"])
652
+ sizes = [os.terminal_size((120, 40)), os.terminal_size((120, 55))]
653
+ monkeypatch.setattr(
654
+ "railmux.cli.os.get_terminal_size",
655
+ lambda _fd: sizes.pop(0) if len(sizes) > 1 else sizes[0],
656
+ )
657
+
658
+ def finish_after_poll(_seconds):
659
+ client.returncode = 0
660
+
661
+ monkeypatch.setattr("railmux.cli.time.sleep", finish_after_poll)
662
+ monkeypatch.setattr(
663
+ "railmux.provider_paths.running_in_managed_windows_wrapper",
664
+ lambda: True,
665
+ )
666
+ notify = MagicMock()
667
+ monkeypatch.setattr("railmux.cli.os.kill", notify)
668
+
669
+ assert _run_tmux_client_with_watchdog(
670
+ ["tmux", "-L", "railmux"], {}, expected_target=target,
671
+ expected_session_id="$7",
672
+ ) == 0
673
+ notify.assert_called_once_with(321, signal.SIGWINCH)
674
+
675
+
676
+ def test_posix_direct_attach_does_not_poll_or_signal_resize(monkeypatch):
677
+ class DirectClient:
678
+ pid = 321
679
+ returncode = None
680
+
681
+ def poll(self):
682
+ return self.returncode
683
+
684
+ client = DirectClient()
685
+ monkeypatch.setattr(
686
+ "railmux.cli.subprocess.Popen", lambda *_args, **_kwargs: client)
687
+ monkeypatch.setattr("railmux.cli.sys.stdin.isatty", lambda: True)
688
+ monkeypatch.setattr("railmux.cli.termios.tcgetattr", lambda _fd: ["saved"])
689
+
690
+ def finish_after_poll(_seconds):
691
+ client.returncode = 0
692
+
693
+ monkeypatch.setattr("railmux.cli.time.sleep", finish_after_poll)
694
+ monkeypatch.setattr(
695
+ "railmux.provider_paths.running_in_managed_windows_wrapper",
696
+ lambda: False,
697
+ )
698
+ get_size = MagicMock()
699
+ notify = MagicMock()
700
+ monkeypatch.setattr("railmux.cli.os.get_terminal_size", get_size)
701
+ monkeypatch.setattr("railmux.cli.os.kill", notify)
702
+
703
+ assert _run_tmux_client_with_watchdog(
704
+ ["tmux", "-L", "railmux"], {},
705
+ ) == 0
706
+ get_size.assert_not_called()
707
+ notify.assert_not_called()
708
+
709
+
635
710
  def test_abrupt_tmux_client_exit_records_disappeared_server(monkeypatch):
636
711
  target = TmuxServerTarget("/tmp/railmux", 77)
637
712
 
@@ -223,6 +223,25 @@ def test_log_path_is_private_versioned_and_utc(tmp_path, monkeypatch):
223
223
  )
224
224
 
225
225
 
226
+ def test_log_path_uses_packaged_python_non_virtualized_root(tmp_path, monkeypatch):
227
+ monkeypatch.setattr(
228
+ windows_install_log,
229
+ "managed_windows_data_root",
230
+ lambda _environ: tmp_path / ".railmux" / "windows",
231
+ )
232
+
233
+ path = install_log_path(
234
+ {
235
+ "LOCALAPPDATA": str(tmp_path / "AppData" / "Local"),
236
+ "USERPROFILE": str(tmp_path),
237
+ },
238
+ version="0.4.0.dev30",
239
+ clock=lambda: 0,
240
+ )
241
+
242
+ assert path.parent == tmp_path / ".railmux" / "windows" / "logs"
243
+
244
+
226
245
  def test_default_command_runner_uses_no_stdin_and_captures_utf8(tmp_path):
227
246
  stream = io.StringIO()
228
247
  path = tmp_path / "install-0.4.0.dev7-20260803T000000Z-4.log"
@@ -291,6 +310,7 @@ def test_compact_reporter_surfaces_extraction_and_package_progress(tmp_path):
291
310
  assert "Extracting private runtime: 42%" in rendered
292
311
  assert "Extracting private runtime: 100%" in rendered
293
312
  assert "Package transaction: 48 packages, 57.40 MiB download." in rendered
313
+ assert "Package transaction resolved: 48 packages." in rendered
294
314
  assert "Downloading packages: 1/48" in rendered
295
315
  assert "Verifying downloaded package signatures" in rendered
296
316
  assert "Installing packages: 1/48" in rendered
@@ -628,6 +628,22 @@ def test_preview_versions_share_one_base_but_use_separate_app_layers(tmp_path):
628
628
  )
629
629
 
630
630
 
631
+ def test_packaged_python_runtime_root_avoids_local_app_data(tmp_path, monkeypatch):
632
+ safe = tmp_path / ".railmux" / "windows"
633
+ monkeypatch.setattr(
634
+ windows_msys2, "managed_windows_data_root", lambda _environ: safe
635
+ )
636
+ environ = {
637
+ "LOCALAPPDATA": str(tmp_path / "AppData" / "Local"),
638
+ "USERPROFILE": str(tmp_path),
639
+ }
640
+
641
+ assert managed_root(environ) == (
642
+ safe / "runtimes" / "shared" / windows_msys2.MSYS2_RUNTIME_ID
643
+ )
644
+ assert windows_msys2._managed_cache(environ) == safe / "cache"
645
+
646
+
631
647
  def test_explicit_user_runtime_is_probed_but_never_requires_managed_marker(tmp_path):
632
648
  root = tmp_path / "用户-owned-msys"
633
649
  runtime = make_runtime(root, managed=False)
@@ -1268,6 +1284,57 @@ def test_verified_base_archive_cache_avoids_a_second_download(tmp_path, monkeypa
1268
1284
  assert source == "verified local cache"
1269
1285
 
1270
1286
 
1287
+ def test_packaged_python_reuses_verified_pre_fix_archive_cache(tmp_path, monkeypatch):
1288
+ payload = b"verified prior cache archive"
1289
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_SIZE", len(payload))
1290
+ monkeypatch.setattr(
1291
+ windows_msys2,
1292
+ "MSYS2_ARCHIVE_SHA256",
1293
+ hashlib.sha256(payload).hexdigest(),
1294
+ )
1295
+ prior = tmp_path / "virtualized-cache"
1296
+ prior.mkdir()
1297
+ (prior / MSYS2_ARCHIVE_NAME).write_bytes(payload)
1298
+ current = tmp_path / "non-virtualized-cache"
1299
+
1300
+ selected, source = windows_msys2._prepare_cached_archive(
1301
+ current,
1302
+ downloader=lambda *_args: pytest.fail("prior cache should avoid downloading"),
1303
+ prior_caches=(prior,),
1304
+ )
1305
+
1306
+ assert selected.read_bytes() == payload
1307
+ assert source == "verified prior local cache"
1308
+
1309
+
1310
+ def test_packaged_python_never_reuses_unverified_pre_fix_archive(
1311
+ tmp_path, monkeypatch
1312
+ ):
1313
+ payload = b"expected archive"
1314
+ monkeypatch.setattr(windows_msys2, "MSYS2_ARCHIVE_SIZE", len(payload))
1315
+ monkeypatch.setattr(
1316
+ windows_msys2,
1317
+ "MSYS2_ARCHIVE_SHA256",
1318
+ hashlib.sha256(payload).hexdigest(),
1319
+ )
1320
+ prior = tmp_path / "virtualized-cache"
1321
+ prior.mkdir()
1322
+ (prior / MSYS2_ARCHIVE_NAME).write_bytes(b"tampered archive")
1323
+ current = tmp_path / "non-virtualized-cache"
1324
+
1325
+ def downloader(_url, destination, _sha256):
1326
+ destination.write_bytes(payload)
1327
+
1328
+ selected, source = windows_msys2._prepare_cached_archive(
1329
+ current,
1330
+ downloader=downloader,
1331
+ prior_caches=(prior,),
1332
+ )
1333
+
1334
+ assert selected.read_bytes() == payload
1335
+ assert source in {label for label, _url in MSYS2_ARCHIVE_SOURCES}
1336
+
1337
+
1271
1338
  def _write_test_tar(path: Path, entries: list[tuple[tarfile.TarInfo, bytes]]) -> None:
1272
1339
  with tarfile.open(path, mode="w:xz") as archive:
1273
1340
  for member, payload in entries:
@@ -10,6 +10,7 @@ from railmux.windows_pacman import (
10
10
  PACMAN_MIRROR_SOURCES,
11
11
  PacmanMirrorError,
12
12
  PacmanMirrorProbe,
13
+ _probe_package_url,
13
14
  deactivate_pacman_hosts,
14
15
  optimize_pacman_mirror,
15
16
  probe_pacman_mirror,
@@ -231,6 +232,40 @@ def test_network_probe_is_bounded_and_rejects_an_https_downgrade():
231
232
  assert requests[0][1] == 8.0
232
233
 
233
234
 
235
+ def test_package_probe_measures_a_real_bounded_transfer():
236
+ requests = []
237
+
238
+ def opener(request, *, timeout):
239
+ requests.append((request, timeout))
240
+ return FakeResponse(
241
+ b"\x28\xb5\x2f\xfd" + b"x" * (256 * 1024),
242
+ final_url="https://mirror.invalid/python.pkg.tar.zst",
243
+ )
244
+
245
+ _probe_package_url(
246
+ "https://mirror.invalid/python.pkg.tar.zst",
247
+ opener=opener,
248
+ clock=lambda: 1.0,
249
+ )
250
+
251
+ assert requests[0][0].get_header("Range") == "bytes=0-262143"
252
+ assert requests[0][1] == 5.0
253
+
254
+
255
+ def test_package_probe_rejects_a_working_but_slow_source():
256
+ times = [0.0, 3.0]
257
+
258
+ with pytest.raises(PacmanMirrorError, match="too slow"):
259
+ _probe_package_url(
260
+ "https://mirror.invalid/python.pkg.tar.zst",
261
+ opener=lambda _request, **_kwargs: FakeResponse(
262
+ b"\x28\xb5\x2f\xfd" + b"x" * (256 * 1024),
263
+ final_url="https://mirror.invalid/python.pkg.tar.zst",
264
+ ),
265
+ clock=lambda: times.pop(0) if len(times) > 1 else times[0],
266
+ )
267
+
268
+
234
269
  def test_optimizer_limits_active_pool_but_reprobes_inactive_candidates(tmp_path):
235
270
  mirrorlist = write_mirrorlist(tmp_path)
236
271
  nju = PACMAN_MIRROR_SOURCES[-1][1]