railmux 0.4.0.dev5__tar.gz → 0.4.0.dev6__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.
- {railmux-0.4.0.dev5/src/railmux.egg-info → railmux-0.4.0.dev6}/PKG-INFO +1 -1
- railmux-0.4.0.dev6/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/windows_msys2.py +424 -5
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_windows_msys2.py +265 -9
- railmux-0.4.0.dev5/src/railmux/__init__.py +0 -1
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/LICENSE +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/README.md +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/pyproject.toml +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/setup.cfg +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/cli.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/discovery.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_server.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/app.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/SOURCES.txt +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_discovery.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_fast_full_window.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_focus_highlight.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_fullscreen_binding.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_soft_quit.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_integration.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tools/wheel_smoke.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0.dev6"
|
|
@@ -6,6 +6,7 @@ remain Windows-native.
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import concurrent.futures
|
|
9
10
|
import hashlib
|
|
10
11
|
import http.client
|
|
11
12
|
import json
|
|
@@ -63,6 +64,13 @@ _HANDOFF_COMMAND = (
|
|
|
63
64
|
_PROBE_TIMEOUT_SECONDS = 15.0
|
|
64
65
|
_DOWNLOAD_LIMIT = 128 * 1024 * 1024
|
|
65
66
|
_DOWNLOAD_LOG_STEP = 8 * 1024 * 1024
|
|
67
|
+
_DOWNLOAD_PROBE_BYTES = 1024 * 1024
|
|
68
|
+
_DOWNLOAD_PROBE_SECONDS = 8.0
|
|
69
|
+
_DOWNLOAD_PROBE_READ_SIZE = 64 * 1024
|
|
70
|
+
_DOWNLOAD_PROBE_TIMEOUT = 10.0
|
|
71
|
+
_DOWNLOAD_SLOW_REMAINING_SECONDS = 60.0
|
|
72
|
+
_DOWNLOAD_SWITCH_RATIO = 1.25
|
|
73
|
+
_CONTENT_RANGE_RE = re.compile(r"bytes (\d+)-(\d+)/(\d+)\Z")
|
|
66
74
|
_VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?\Z")
|
|
67
75
|
|
|
68
76
|
_INITIAL_UPDATE_COMMAND = (
|
|
@@ -92,6 +100,22 @@ Runner = Callable[..., subprocess.CompletedProcess]
|
|
|
92
100
|
Downloader = Callable[[str, Path, str], None]
|
|
93
101
|
|
|
94
102
|
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class _ArchiveProbe:
|
|
105
|
+
label: str
|
|
106
|
+
url: str
|
|
107
|
+
data: bytes
|
|
108
|
+
elapsed: float
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def rate(self) -> float:
|
|
112
|
+
return len(self.data) / max(self.elapsed, 0.001)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
ArchiveProbe = Callable[[str, str, int], _ArchiveProbe]
|
|
116
|
+
ArchiveResume = Callable[[str, str, int, int, Callable[[bytes], None]], None]
|
|
117
|
+
|
|
118
|
+
|
|
95
119
|
def _format_download_size(size: int) -> str:
|
|
96
120
|
if size >= 1024 * 1024:
|
|
97
121
|
return f"{size / (1024 * 1024):.1f} MiB"
|
|
@@ -147,6 +171,198 @@ class _DownloadProgress:
|
|
|
147
171
|
self._active_line = False
|
|
148
172
|
|
|
149
173
|
|
|
174
|
+
def _format_download_rate(rate: float) -> str:
|
|
175
|
+
return f"{_format_download_size(int(rate))}/s"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _validate_https_response(url: str, response: object) -> None:
|
|
179
|
+
if urllib.parse.urlsplit(url).scheme.lower() != "https":
|
|
180
|
+
raise RuntimeInstallError("the MSYS2 runtime source did not use HTTPS")
|
|
181
|
+
final_url = getattr(response, "geturl", lambda: url)()
|
|
182
|
+
if urllib.parse.urlsplit(final_url).scheme.lower() != "https":
|
|
183
|
+
raise RuntimeInstallError(
|
|
184
|
+
"the MSYS2 runtime source redirected outside HTTPS"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_range_response(
|
|
189
|
+
url: str,
|
|
190
|
+
response: object,
|
|
191
|
+
*,
|
|
192
|
+
start: int,
|
|
193
|
+
end: int,
|
|
194
|
+
expected_size: int,
|
|
195
|
+
) -> None:
|
|
196
|
+
_validate_https_response(url, response)
|
|
197
|
+
if getattr(response, "status", None) != 206:
|
|
198
|
+
raise RuntimeInstallError("the MSYS2 source did not honor a range request")
|
|
199
|
+
raw_range = response.headers.get("Content-Range", "")
|
|
200
|
+
match = _CONTENT_RANGE_RE.fullmatch(raw_range)
|
|
201
|
+
if match is None:
|
|
202
|
+
raise RuntimeInstallError("the MSYS2 source returned an invalid content range")
|
|
203
|
+
actual_start, actual_end, actual_size = (int(value) for value in match.groups())
|
|
204
|
+
if (
|
|
205
|
+
actual_start != start
|
|
206
|
+
or actual_end != end
|
|
207
|
+
or actual_size != expected_size
|
|
208
|
+
):
|
|
209
|
+
raise RuntimeInstallError("the MSYS2 source returned the wrong content range")
|
|
210
|
+
raw_length = response.headers.get("Content-Length")
|
|
211
|
+
if raw_length is not None:
|
|
212
|
+
try:
|
|
213
|
+
actual_length = int(raw_length)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
actual_length = -1
|
|
216
|
+
if actual_length != end - start + 1:
|
|
217
|
+
raise RuntimeInstallError("the MSYS2 source returned the wrong range size")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _range_request(url: str, *, start: int, end: int) -> urllib.request.Request:
|
|
221
|
+
return urllib.request.Request(
|
|
222
|
+
url,
|
|
223
|
+
headers={
|
|
224
|
+
"Accept-Encoding": "identity",
|
|
225
|
+
"Range": f"bytes={start}-{end}",
|
|
226
|
+
"User-Agent": "Railmux-MSYS2-bootstrap/1",
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _probe_archive_source(
|
|
232
|
+
label: str,
|
|
233
|
+
url: str,
|
|
234
|
+
expected_size: int,
|
|
235
|
+
*,
|
|
236
|
+
opener: Callable[..., object] = urllib.request.urlopen,
|
|
237
|
+
clock: Callable[[], float] = time.monotonic,
|
|
238
|
+
) -> _ArchiveProbe:
|
|
239
|
+
"""Read a bounded prefix used both for selection and final assembly."""
|
|
240
|
+
end = min(_DOWNLOAD_PROBE_BYTES, expected_size) - 1
|
|
241
|
+
request = _range_request(url, start=0, end=end)
|
|
242
|
+
started = clock()
|
|
243
|
+
data = bytearray()
|
|
244
|
+
try:
|
|
245
|
+
with opener(request, timeout=_DOWNLOAD_PROBE_TIMEOUT) as response:
|
|
246
|
+
_validate_range_response(
|
|
247
|
+
url,
|
|
248
|
+
response,
|
|
249
|
+
start=0,
|
|
250
|
+
end=end,
|
|
251
|
+
expected_size=expected_size,
|
|
252
|
+
)
|
|
253
|
+
while len(data) <= end:
|
|
254
|
+
remaining = end + 1 - len(data)
|
|
255
|
+
try:
|
|
256
|
+
chunk = response.read(
|
|
257
|
+
min(_DOWNLOAD_PROBE_READ_SIZE, remaining)
|
|
258
|
+
)
|
|
259
|
+
except (OSError, http.client.HTTPException):
|
|
260
|
+
if data:
|
|
261
|
+
break
|
|
262
|
+
raise
|
|
263
|
+
if not chunk:
|
|
264
|
+
break
|
|
265
|
+
data.extend(chunk)
|
|
266
|
+
if clock() - started >= _DOWNLOAD_PROBE_SECONDS:
|
|
267
|
+
break
|
|
268
|
+
except RuntimeInstallError:
|
|
269
|
+
raise
|
|
270
|
+
except (OSError, urllib.error.URLError, http.client.HTTPException) as exc:
|
|
271
|
+
raise RuntimeInstallError("could not probe the MSYS2 source") from exc
|
|
272
|
+
if not data:
|
|
273
|
+
raise RuntimeInstallError("the MSYS2 source returned no probe data")
|
|
274
|
+
return _ArchiveProbe(
|
|
275
|
+
label=label,
|
|
276
|
+
url=url,
|
|
277
|
+
data=bytes(data),
|
|
278
|
+
elapsed=max(clock() - started, 0.001),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resume_archive_source(
|
|
283
|
+
_label: str,
|
|
284
|
+
url: str,
|
|
285
|
+
offset: int,
|
|
286
|
+
expected_size: int,
|
|
287
|
+
write_chunk: Callable[[bytes], None],
|
|
288
|
+
*,
|
|
289
|
+
opener: Callable[..., object] = urllib.request.urlopen,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Append the exact remaining range from one approved source."""
|
|
292
|
+
if offset >= expected_size:
|
|
293
|
+
return
|
|
294
|
+
request = _range_request(url, start=offset, end=expected_size - 1)
|
|
295
|
+
try:
|
|
296
|
+
with opener(request, timeout=60) as response:
|
|
297
|
+
_validate_range_response(
|
|
298
|
+
url,
|
|
299
|
+
response,
|
|
300
|
+
start=offset,
|
|
301
|
+
end=expected_size - 1,
|
|
302
|
+
expected_size=expected_size,
|
|
303
|
+
)
|
|
304
|
+
while True:
|
|
305
|
+
chunk = response.read(1024 * 1024)
|
|
306
|
+
if not chunk:
|
|
307
|
+
break
|
|
308
|
+
write_chunk(chunk)
|
|
309
|
+
except RuntimeInstallError:
|
|
310
|
+
raise
|
|
311
|
+
except (OSError, urllib.error.URLError, http.client.HTTPException) as exc:
|
|
312
|
+
raise RuntimeInstallError("could not resume the MSYS2 source") from exc
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _probe_other_sources(
|
|
316
|
+
sources: Sequence[tuple[str, str]],
|
|
317
|
+
*,
|
|
318
|
+
expected_size: int,
|
|
319
|
+
probe_source: ArchiveProbe,
|
|
320
|
+
) -> list[_ArchiveProbe]:
|
|
321
|
+
if not sources:
|
|
322
|
+
return []
|
|
323
|
+
results: list[_ArchiveProbe] = []
|
|
324
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(sources)) as pool:
|
|
325
|
+
futures = {
|
|
326
|
+
pool.submit(probe_source, label, url, expected_size): label
|
|
327
|
+
for label, url in sources
|
|
328
|
+
}
|
|
329
|
+
for future in concurrent.futures.as_completed(futures):
|
|
330
|
+
label = futures[future]
|
|
331
|
+
try:
|
|
332
|
+
result = future.result()
|
|
333
|
+
except RuntimeInstallError as exc:
|
|
334
|
+
print(f" {label}: unavailable ({exc})", file=sys.stderr)
|
|
335
|
+
else:
|
|
336
|
+
results.append(result)
|
|
337
|
+
print(
|
|
338
|
+
f" {label}: {_format_download_rate(result.rate)}",
|
|
339
|
+
file=sys.stderr,
|
|
340
|
+
)
|
|
341
|
+
return results
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _probe_is_slow(probe: _ArchiveProbe, *, expected_size: int) -> bool:
|
|
345
|
+
remaining = max(0, expected_size - len(probe.data))
|
|
346
|
+
return remaining / probe.rate > _DOWNLOAD_SLOW_REMAINING_SECONDS
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _select_probe(
|
|
350
|
+
primary: _ArchiveProbe | None,
|
|
351
|
+
alternatives: Sequence[_ArchiveProbe],
|
|
352
|
+
) -> _ArchiveProbe:
|
|
353
|
+
candidates = ([primary] if primary is not None else []) + list(alternatives)
|
|
354
|
+
if not candidates:
|
|
355
|
+
raise RuntimeInstallError("no approved MSYS2 source could be probed")
|
|
356
|
+
fastest = max(candidates, key=lambda candidate: candidate.rate)
|
|
357
|
+
if (
|
|
358
|
+
primary is not None
|
|
359
|
+
and fastest is not primary
|
|
360
|
+
and fastest.rate < primary.rate * _DOWNLOAD_SWITCH_RATIO
|
|
361
|
+
):
|
|
362
|
+
return primary
|
|
363
|
+
return fastest
|
|
364
|
+
|
|
365
|
+
|
|
150
366
|
@dataclass(frozen=True)
|
|
151
367
|
class Msys2Runtime:
|
|
152
368
|
root: Path
|
|
@@ -361,17 +577,183 @@ def download_verified(url: str, destination: Path, sha256: str) -> None:
|
|
|
361
577
|
progress.update(total, expected, final=True)
|
|
362
578
|
|
|
363
579
|
|
|
364
|
-
def
|
|
580
|
+
def download_adaptive(
|
|
365
581
|
destination: Path,
|
|
366
582
|
sha256: str,
|
|
367
583
|
*,
|
|
368
584
|
sources: Sequence[tuple[str, str]] = MSYS2_ARCHIVE_SOURCES,
|
|
369
|
-
|
|
585
|
+
expected_size: int = MSYS2_ARCHIVE_SIZE,
|
|
586
|
+
probe_source: ArchiveProbe = _probe_archive_source,
|
|
587
|
+
resume_source: ArchiveResume = _resume_archive_source,
|
|
588
|
+
) -> str:
|
|
589
|
+
"""Select a responsive approved source, then resume across failures."""
|
|
590
|
+
if not sources:
|
|
591
|
+
raise RuntimeInstallError("no approved MSYS2 download source is configured")
|
|
592
|
+
if expected_size <= 0 or expected_size > _DOWNLOAD_LIMIT:
|
|
593
|
+
raise RuntimeInstallError("the pinned MSYS2 archive size is invalid")
|
|
594
|
+
for _label, url in sources:
|
|
595
|
+
if urllib.parse.urlsplit(url).scheme.lower() != "https":
|
|
596
|
+
raise RuntimeInstallError("the MSYS2 runtime source did not use HTTPS")
|
|
597
|
+
|
|
598
|
+
primary: _ArchiveProbe | None = None
|
|
599
|
+
remaining_sources = list(sources[1:])
|
|
600
|
+
primary_label, primary_url = sources[0]
|
|
601
|
+
print(f"Probing {primary_label}…", flush=True)
|
|
602
|
+
try:
|
|
603
|
+
primary = probe_source(primary_label, primary_url, expected_size)
|
|
604
|
+
except RuntimeInstallError as exc:
|
|
605
|
+
print(
|
|
606
|
+
f"{primary_label} probe failed ({exc}); checking approved alternatives…",
|
|
607
|
+
file=sys.stderr,
|
|
608
|
+
)
|
|
609
|
+
else:
|
|
610
|
+
print(
|
|
611
|
+
f" {primary.label}: {_format_download_rate(primary.rate)}",
|
|
612
|
+
file=sys.stderr,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
alternatives: list[_ArchiveProbe] = []
|
|
616
|
+
if primary is None or _probe_is_slow(primary, expected_size=expected_size):
|
|
617
|
+
if primary is not None:
|
|
618
|
+
remaining_seconds = (
|
|
619
|
+
expected_size - len(primary.data)
|
|
620
|
+
) / primary.rate
|
|
621
|
+
print(
|
|
622
|
+
f"{primary.label} is slow (~{remaining_seconds:.0f}s remaining); "
|
|
623
|
+
"checking approved alternatives…",
|
|
624
|
+
file=sys.stderr,
|
|
625
|
+
)
|
|
626
|
+
alternatives = _probe_other_sources(
|
|
627
|
+
remaining_sources,
|
|
628
|
+
expected_size=expected_size,
|
|
629
|
+
probe_source=probe_source,
|
|
630
|
+
)
|
|
631
|
+
selected = _select_probe(primary, alternatives)
|
|
632
|
+
if primary is not None and selected is not primary:
|
|
633
|
+
print(
|
|
634
|
+
f"Switching to {selected.label} "
|
|
635
|
+
f"({_format_download_rate(selected.rate)}).",
|
|
636
|
+
file=sys.stderr,
|
|
637
|
+
)
|
|
638
|
+
elif alternatives:
|
|
639
|
+
print(
|
|
640
|
+
f"Continuing with {selected.label}; no approved source was "
|
|
641
|
+
"materially faster.",
|
|
642
|
+
file=sys.stderr,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
measured = {probe.url: probe for probe in alternatives}
|
|
646
|
+
if primary is not None:
|
|
647
|
+
measured[primary.url] = primary
|
|
648
|
+
ordered = [selected]
|
|
649
|
+
ordered.extend(
|
|
650
|
+
probe
|
|
651
|
+
for probe in sorted(
|
|
652
|
+
measured.values(),
|
|
653
|
+
key=lambda candidate: candidate.rate,
|
|
654
|
+
reverse=True,
|
|
655
|
+
)
|
|
656
|
+
if probe.url != selected.url
|
|
657
|
+
)
|
|
658
|
+
measured_urls = set(measured)
|
|
659
|
+
ordered.extend(
|
|
660
|
+
_ArchiveProbe(label, url, b"", 1.0)
|
|
661
|
+
for label, url in sources
|
|
662
|
+
if url not in measured_urls
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
digest = hashlib.sha256()
|
|
666
|
+
offset = 0
|
|
667
|
+
try:
|
|
668
|
+
with destination.open("xb") as output:
|
|
669
|
+
output.write(selected.data)
|
|
670
|
+
digest.update(selected.data)
|
|
671
|
+
offset = len(selected.data)
|
|
672
|
+
if offset > expected_size:
|
|
673
|
+
raise RuntimeInstallError("the MSYS2 probe exceeded the pinned size")
|
|
674
|
+
|
|
675
|
+
last_error: RuntimeInstallError | None = None
|
|
676
|
+
for candidate in ordered:
|
|
677
|
+
progress = _DownloadProgress(candidate.url)
|
|
678
|
+
progress.update(offset, expected_size)
|
|
679
|
+
|
|
680
|
+
def write_chunk(chunk: bytes) -> None:
|
|
681
|
+
nonlocal offset
|
|
682
|
+
if offset + len(chunk) > expected_size:
|
|
683
|
+
raise RuntimeInstallError(
|
|
684
|
+
"the MSYS2 download exceeded its pinned size"
|
|
685
|
+
)
|
|
686
|
+
output.write(chunk)
|
|
687
|
+
digest.update(chunk)
|
|
688
|
+
offset += len(chunk)
|
|
689
|
+
progress.update(offset, expected_size)
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
resume_source(
|
|
693
|
+
candidate.label,
|
|
694
|
+
candidate.url,
|
|
695
|
+
offset,
|
|
696
|
+
expected_size,
|
|
697
|
+
write_chunk,
|
|
698
|
+
)
|
|
699
|
+
except RuntimeInstallError as exc:
|
|
700
|
+
last_error = exc
|
|
701
|
+
progress.abort()
|
|
702
|
+
print(
|
|
703
|
+
f"{candidate.label} transfer failed ({exc}); "
|
|
704
|
+
"resuming from another approved source…",
|
|
705
|
+
file=sys.stderr,
|
|
706
|
+
)
|
|
707
|
+
continue
|
|
708
|
+
if offset != expected_size:
|
|
709
|
+
last_error = RuntimeInstallError(
|
|
710
|
+
"the MSYS2 source ended before the pinned size"
|
|
711
|
+
)
|
|
712
|
+
progress.abort()
|
|
713
|
+
print(
|
|
714
|
+
f"{candidate.label} transfer ended early; resuming "
|
|
715
|
+
"from another approved source…",
|
|
716
|
+
file=sys.stderr,
|
|
717
|
+
)
|
|
718
|
+
continue
|
|
719
|
+
progress.update(offset, expected_size, final=True)
|
|
720
|
+
break
|
|
721
|
+
else:
|
|
722
|
+
raise RuntimeInstallError(
|
|
723
|
+
"every approved MSYS2 source failed during transfer"
|
|
724
|
+
) from last_error
|
|
725
|
+
|
|
726
|
+
if digest.hexdigest().lower() != sha256.lower():
|
|
727
|
+
raise RuntimeInstallError(
|
|
728
|
+
"the downloaded MSYS2 archive failed SHA-256 verification"
|
|
729
|
+
)
|
|
730
|
+
except (RuntimeInstallError, OSError) as exc:
|
|
731
|
+
try:
|
|
732
|
+
destination.unlink(missing_ok=True)
|
|
733
|
+
except OSError as cleanup_error:
|
|
734
|
+
raise RuntimeInstallError(
|
|
735
|
+
"could not remove an incomplete MSYS2 download"
|
|
736
|
+
) from cleanup_error
|
|
737
|
+
if isinstance(exc, RuntimeInstallError):
|
|
738
|
+
raise
|
|
739
|
+
raise RuntimeInstallError("could not write the pinned MSYS2 runtime") from exc
|
|
740
|
+
return selected.label
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _download_from_sources_sequentially(
|
|
744
|
+
destination: Path,
|
|
745
|
+
sha256: str,
|
|
746
|
+
*,
|
|
747
|
+
sources: Sequence[tuple[str, str]],
|
|
748
|
+
downloader: Downloader,
|
|
370
749
|
) -> str:
|
|
371
|
-
"""
|
|
750
|
+
"""Compatibility fallback when range selection is unavailable."""
|
|
372
751
|
last_error: RuntimeInstallError | None = None
|
|
373
752
|
for index, (label, url) in enumerate(sources, start=1):
|
|
374
|
-
print(
|
|
753
|
+
print(
|
|
754
|
+
f"Downloading from {label} ({index}/{len(sources)})…",
|
|
755
|
+
flush=True,
|
|
756
|
+
)
|
|
375
757
|
try:
|
|
376
758
|
downloader(url, destination, sha256)
|
|
377
759
|
except RuntimeInstallError as exc:
|
|
@@ -394,6 +776,43 @@ def download_from_sources(
|
|
|
394
776
|
) from last_error
|
|
395
777
|
|
|
396
778
|
|
|
779
|
+
def download_from_sources(
|
|
780
|
+
destination: Path,
|
|
781
|
+
sha256: str,
|
|
782
|
+
*,
|
|
783
|
+
sources: Sequence[tuple[str, str]] = MSYS2_ARCHIVE_SOURCES,
|
|
784
|
+
downloader: Downloader | None = None,
|
|
785
|
+
) -> str:
|
|
786
|
+
"""Use adaptive range selection, retaining a full-download fallback."""
|
|
787
|
+
if downloader is not None:
|
|
788
|
+
return _download_from_sources_sequentially(
|
|
789
|
+
destination,
|
|
790
|
+
sha256,
|
|
791
|
+
sources=sources,
|
|
792
|
+
downloader=downloader,
|
|
793
|
+
)
|
|
794
|
+
try:
|
|
795
|
+
return download_adaptive(destination, sha256, sources=sources)
|
|
796
|
+
except RuntimeInstallError as exc:
|
|
797
|
+
print(
|
|
798
|
+
f"Adaptive MSYS2 download failed ({exc}); retrying ordinary "
|
|
799
|
+
"approved-source downloads…",
|
|
800
|
+
file=sys.stderr,
|
|
801
|
+
)
|
|
802
|
+
try:
|
|
803
|
+
destination.unlink(missing_ok=True)
|
|
804
|
+
except OSError as cleanup_error:
|
|
805
|
+
raise RuntimeInstallError(
|
|
806
|
+
"could not remove an incomplete MSYS2 download"
|
|
807
|
+
) from cleanup_error
|
|
808
|
+
return _download_from_sources_sequentially(
|
|
809
|
+
destination,
|
|
810
|
+
sha256,
|
|
811
|
+
sources=sources,
|
|
812
|
+
downloader=download_verified,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
|
|
397
816
|
def _run_checked(
|
|
398
817
|
argv: Sequence[str],
|
|
399
818
|
*,
|
|
@@ -471,7 +890,7 @@ def install_managed_runtime(
|
|
|
471
890
|
*,
|
|
472
891
|
version: str,
|
|
473
892
|
environ: Mapping[str, str],
|
|
474
|
-
downloader: Downloader =
|
|
893
|
+
downloader: Downloader | None = None,
|
|
475
894
|
runner: Runner = subprocess.run,
|
|
476
895
|
probe: Probe = _probe,
|
|
477
896
|
lock_factory: Callable[[Path], object] = install_lock,
|