gitpane 1.0.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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
"""Bound every child process the application runs: its lifetime, its byte streams, its share of a window's concurrency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QElapsedTimer, QObject, QProcess, QProcessEnvironment, QTimer, Signal
|
|
15
|
+
|
|
16
|
+
import gitpane.process.exact_arguments
|
|
17
|
+
from gitpane.config import (
|
|
18
|
+
COMMIT_LIMIT_BYTES,
|
|
19
|
+
DECOMPOSED_UNICODE_FORM,
|
|
20
|
+
GCM_ENVIRONMENT_PREFIX,
|
|
21
|
+
GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME,
|
|
22
|
+
GIT_FSMONITOR_SETTING,
|
|
23
|
+
GIT_INERT_EDITOR,
|
|
24
|
+
GIT_LOCAL_ENVIRONMENT_NAMES,
|
|
25
|
+
GIT_LOCAL_ENVIRONMENT_PREFIXES,
|
|
26
|
+
GIT_MUTATION_ENVIRONMENT_NAME_SET,
|
|
27
|
+
GLOBAL_PROCESS_LIMIT,
|
|
28
|
+
GLOBAL_QUEUE_LIMIT,
|
|
29
|
+
ISOLATED_INTERPRETER_OPTIONS,
|
|
30
|
+
MACOS_PLATFORM,
|
|
31
|
+
NETWORK_ENVIRONMENT_VALUE_SET_BY_NAME,
|
|
32
|
+
PARSING_BUDGET_MS,
|
|
33
|
+
PROCESS_CHUNK_BYTES,
|
|
34
|
+
PROCESS_GROUP_POLL_MS,
|
|
35
|
+
PROCESS_IDLE_TIMEOUT_MS,
|
|
36
|
+
PROCESS_TERMINATE_TIMEOUT_MS,
|
|
37
|
+
SESSION_PROCESS_LIMIT,
|
|
38
|
+
SESSION_QUEUE_LIMIT,
|
|
39
|
+
SSH_ENVIRONMENT_NAMES,
|
|
40
|
+
STDERR_LIMIT_BYTES,
|
|
41
|
+
TRACE_ENVIRONMENT_PREFIXES,
|
|
42
|
+
TRACE_STORAGE_LIMIT_BYTES,
|
|
43
|
+
WINDOWS_PLATFORM,
|
|
44
|
+
ProcessPriority,
|
|
45
|
+
)
|
|
46
|
+
from gitpane.operation.diagnostic import MASKED_VALUE, SENSITIVE_NAME_PATTERN, SensitiveTextFilter, SensitiveTextStream, mask_sensitive_arguments
|
|
47
|
+
from gitpane.process.exact_arguments import EXACT_ARGUMENTS_ENVIRONMENT_NAME, EXACT_ARGUMENTS_START_FAILED_EXIT_CODE, EXACT_ARGUMENTS_START_FAILURE_PREFIX
|
|
48
|
+
from gitpane.process.process_group import NativeProcessGroup, ProcessGroup, supervisor_interpreter_filename
|
|
49
|
+
from gitpane.tool.editor_bridge import EDITOR_ENVIRONMENT_NAME_SET
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from gitpane.tool.editor import EditorExecutionContext
|
|
53
|
+
|
|
54
|
+
# The profile of a plain read: a process that changes nothing and needs no supervised launcher.
|
|
55
|
+
READ_PROFILE = "read"
|
|
56
|
+
# The profile of a process that talks to the network: isolated environment, error output kept for the console, supervised launcher.
|
|
57
|
+
NETWORK_PROFILE = "network"
|
|
58
|
+
# The profile of a supervised process that writes locally or may run a hook, a filter or the editor.
|
|
59
|
+
LOCAL_WRITE_PROFILE = "local-write"
|
|
60
|
+
# The profile of a process that exchanges a secret: its output is never published.
|
|
61
|
+
CREDENTIAL_PROFILE = "credential"
|
|
62
|
+
TRACKED_PROCESS_PROFILE_SET = frozenset({LOCAL_WRITE_PROFILE, NETWORK_PROFILE, CREDENTIAL_PROFILE})
|
|
63
|
+
# Built from the supervised profiles instead of listed again: the three names that decide supervision
|
|
64
|
+
# are written once, and the only profile that adds nothing to supervise is the plain read.
|
|
65
|
+
ACCEPTED_PROCESS_PROFILE_SET = TRACKED_PROCESS_PROFILE_SET | frozenset({READ_PROFILE})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProcessStatus(Enum):
|
|
69
|
+
"""How a process request ended, or that it is still running; the value is the word the ``--log`` file writes."""
|
|
70
|
+
|
|
71
|
+
RUNNING = "running"
|
|
72
|
+
"""The request was launched and has not ended yet; no published result carries it."""
|
|
73
|
+
SUCCESS = "success"
|
|
74
|
+
"""The command ran to its end with exit code 0 and no cause of failure."""
|
|
75
|
+
FAILED = "failed"
|
|
76
|
+
"""The command ran to its end with another exit code, or with a cause of failure."""
|
|
77
|
+
TIMEOUT = "timeout"
|
|
78
|
+
"""The command was stopped because it ran past its time limit."""
|
|
79
|
+
CANCELLED = "cancelled"
|
|
80
|
+
"""The user or the closing runner stopped the request, queued or running."""
|
|
81
|
+
QUEUE_FULL = "queue_full"
|
|
82
|
+
"""The request was refused because too many were already waiting; it never ran."""
|
|
83
|
+
START_FAILED = "start_failed"
|
|
84
|
+
"""The program could not be launched, refused by its own check or by the system."""
|
|
85
|
+
IDLE_TIMEOUT = "idle_timeout"
|
|
86
|
+
"""The command was stopped because it wrote nothing for too long."""
|
|
87
|
+
VOLUME = "volume"
|
|
88
|
+
"""The command was stopped because it wrote more than its output limit."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Cause of a failure that wrote no error output; written here and recognised by the console, which does not repeat it.
|
|
92
|
+
EXIT_CODE_CAUSE_TEMPLATE = "Process exited with code {return_code}"
|
|
93
|
+
# The systems whose Qt rewrites every argument QProcess passes into its decomposed Unicode form.
|
|
94
|
+
DECOMPOSED_ARGUMENT_PLATFORM_SET = frozenset({MACOS_PLATFORM})
|
|
95
|
+
# The systems where stopping a read can leave running a program it started, with the files it opened still open. On
|
|
96
|
+
# Windows, Git for Windows' cmd\git.exe is a wrapper that starts the real git.exe: stopping the wrapper leaves git.exe
|
|
97
|
+
# alive until its next write to the closed output fails, and Windows refuses to delete a file it still has open.
|
|
98
|
+
STOPPED_READ_LEAVES_CHILD_PLATFORM_SET = frozenset({WINDOWS_PLATFORM})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ProcessRequest:
|
|
103
|
+
"""Capture immutable launch identity and limits before a command enters the queue.
|
|
104
|
+
|
|
105
|
+
The priority only decides which waiting request takes the next free slot; it says nothing about what the command
|
|
106
|
+
may do, so it never takes part in the choice of a launch profile or of supervision.
|
|
107
|
+
|
|
108
|
+
``accepted_return_codes`` are the exit codes the caller does not hold for an error, such as 1 for a reference
|
|
109
|
+
lookup that may find nothing. Only the operation console reads them; the launch and the result status ignore them.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
request_id: str
|
|
113
|
+
program_filename: str
|
|
114
|
+
arguments: list[str]
|
|
115
|
+
working_dir: str
|
|
116
|
+
timeout_ms: int
|
|
117
|
+
session_id: str = "application"
|
|
118
|
+
stdout_limit_bytes: int | None = COMMIT_LIMIT_BYTES
|
|
119
|
+
stderr_limit_bytes: int = STDERR_LIMIT_BYTES
|
|
120
|
+
idle_timeout_ms: int = PROCESS_IDLE_TIMEOUT_MS
|
|
121
|
+
stdin: bytes | None = None
|
|
122
|
+
environment_by_name: dict[str, str] = field(default_factory=dict)
|
|
123
|
+
operation: str = "Read"
|
|
124
|
+
wait_for_exit_on_stop: bool = False
|
|
125
|
+
profile: str = READ_PROFILE
|
|
126
|
+
operation_id: str = ""
|
|
127
|
+
before_start: Callable[[], None] | None = None
|
|
128
|
+
editor_context: EditorExecutionContext | None = field(default=None, repr=False)
|
|
129
|
+
suppress_editor: bool = False
|
|
130
|
+
priority: ProcessPriority = ProcessPriority.USER
|
|
131
|
+
# The group a supervised request runs in, when it needs another one than its runner's default; None keeps the default.
|
|
132
|
+
process_group_factory: Callable[[], ProcessGroup] | None = field(default=None, repr=False)
|
|
133
|
+
accepted_return_codes: tuple[int, ...] = (0,)
|
|
134
|
+
# The caller owns this file until the terminal result; Qt opens it only when the request leaves the queue.
|
|
135
|
+
stdin_filename: str | None = None
|
|
136
|
+
# The caller deletes the files this command opens as soon as it ends. Where a stopped read can leave a program it
|
|
137
|
+
# started running, such a read runs under the supervisor, so its end is only reported once nothing it started is left.
|
|
138
|
+
opens_files_deleted_at_end: bool = False
|
|
139
|
+
|
|
140
|
+
def __post_init__(self) -> None:
|
|
141
|
+
"""Reject competing input sources before a process can enter the queue."""
|
|
142
|
+
if self.stdin is not None and self.stdin_filename is not None:
|
|
143
|
+
raise ValueError("stdin and stdin_filename are mutually exclusive")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class ProcessResult:
|
|
148
|
+
"""Report one terminal outcome with complete bounded stderr and launch context.
|
|
149
|
+
|
|
150
|
+
The two timings split the whole life of the request and lose nothing between them: the queue wait runs from the
|
|
151
|
+
moment the request was received to the moment it left the queue, or to its outcome when it never left; the
|
|
152
|
+
duration runs from that departure to the outcome, and is zero for a request that never started.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
request: ProcessRequest
|
|
156
|
+
status: ProcessStatus
|
|
157
|
+
return_code: int | None
|
|
158
|
+
cause: str
|
|
159
|
+
stderr: bytes
|
|
160
|
+
queue_wait_ms: int = 0
|
|
161
|
+
duration_ms: int = 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
GIT_GLOBAL_FLAG_SET = frozenset({"--literal-pathspecs", "--no-literal-pathspecs"})
|
|
165
|
+
GIT_GLOBAL_VALUE_OPTION_SET = frozenset({"-c", "-C", "--git-dir", "--work-tree"})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def needs_exact_arguments(platform: str, program_filename: str, arguments: list[str]) -> bool:
|
|
169
|
+
"""Tell whether a command must reach its program through the exact-arguments relay on ``platform``.
|
|
170
|
+
|
|
171
|
+
Only a system whose Qt decomposes arguments needs it, and there only for a command whose program or one argument
|
|
172
|
+
changes when decomposed: an all-ASCII command, the usual case, is started directly and costs nothing more.
|
|
173
|
+
"""
|
|
174
|
+
if platform not in DECOMPOSED_ARGUMENT_PLATFORM_SET:
|
|
175
|
+
return False
|
|
176
|
+
if not unicodedata.is_normalized(DECOMPOSED_UNICODE_FORM, program_filename):
|
|
177
|
+
return True
|
|
178
|
+
for argument in arguments:
|
|
179
|
+
if not unicodedata.is_normalized(DECOMPOSED_UNICODE_FORM, argument):
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def exact_arguments_relay_filename() -> str:
|
|
185
|
+
"""Answer the absolute file of the exact-arguments relay, as imported from the installation; a missing file is a launch error."""
|
|
186
|
+
relay_filename = os.path.abspath(gitpane.process.exact_arguments.__file__)
|
|
187
|
+
if not os.path.isfile(relay_filename):
|
|
188
|
+
raise OSError("The exact-arguments relay is not available: " + relay_filename)
|
|
189
|
+
return relay_filename
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def relay_start_failure_cause(relayed: bool, return_code: int | None, stderr: bytes) -> str | None:
|
|
193
|
+
"""Answer the relay's refusal line when a relayed command never started, or None when the command itself ran.
|
|
194
|
+
|
|
195
|
+
QProcess starts the relay without trouble, so a program the relay cannot start shows up as an ordinary exit. Only
|
|
196
|
+
the relay's exit code together with its refusal line means the program did not start: a command that ran may
|
|
197
|
+
answer 127 on its own, and its answer must stay a return code.
|
|
198
|
+
"""
|
|
199
|
+
if not relayed or return_code != EXACT_ARGUMENTS_START_FAILED_EXIT_CODE:
|
|
200
|
+
return None
|
|
201
|
+
error_text = stderr.decode("utf-8", "replace").strip()
|
|
202
|
+
if not error_text.startswith(EXACT_ARGUMENTS_START_FAILURE_PREFIX):
|
|
203
|
+
return None
|
|
204
|
+
return error_text
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def stopped_read_leaves_child(platform: str) -> bool:
|
|
208
|
+
"""Tell whether stopping a read on ``platform`` can leave running a program it started, with its files still open."""
|
|
209
|
+
return platform in STOPPED_READ_LEAVES_CHILD_PLATFORM_SET
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def is_supervised_request(request: ProcessRequest, platform: str = sys.platform) -> bool:
|
|
213
|
+
"""Tell whether a request must run under the supervisor that tracks its native descendants on ``platform``.
|
|
214
|
+
|
|
215
|
+
Writes and waited-for commands always are. A read is only when its caller deletes the files it opens right after
|
|
216
|
+
its end, and only on a system where stopping it can leave a program it started holding them: elsewhere the read
|
|
217
|
+
stays a plain process, which costs no interpreter and no group.
|
|
218
|
+
"""
|
|
219
|
+
if request.profile in TRACKED_PROCESS_PROFILE_SET or request.wait_for_exit_on_stop:
|
|
220
|
+
return True
|
|
221
|
+
return request.opens_files_deleted_at_end and stopped_read_leaves_child(platform)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def git_subcommand_index(arguments: list[str]) -> int | None:
|
|
225
|
+
"""Return the position of the Git subcommand behind the global options placed in front of it, or nothing."""
|
|
226
|
+
skip_value = False
|
|
227
|
+
for position, argument in enumerate(arguments):
|
|
228
|
+
if skip_value:
|
|
229
|
+
skip_value = False
|
|
230
|
+
continue
|
|
231
|
+
if argument in GIT_GLOBAL_VALUE_OPTION_SET:
|
|
232
|
+
skip_value = True
|
|
233
|
+
continue
|
|
234
|
+
option_name, separator, _value = argument.partition("=")
|
|
235
|
+
if separator and option_name in GIT_GLOBAL_VALUE_OPTION_SET:
|
|
236
|
+
continue
|
|
237
|
+
if argument in GIT_GLOBAL_FLAG_SET:
|
|
238
|
+
continue
|
|
239
|
+
return position
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def git_subcommand(arguments: list[str]) -> str:
|
|
244
|
+
"""Return the Git subcommand hidden behind the global options placed in front of it, or an empty name."""
|
|
245
|
+
position = git_subcommand_index(arguments)
|
|
246
|
+
return "" if position is None else arguments[position]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
OPTION_PREFIX = "-"
|
|
250
|
+
OPTION_VALUE_SEPARATOR = "="
|
|
251
|
+
PATHSPEC_SEPARATOR = "--"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class ReadForm:
|
|
256
|
+
"""Describe the shape a subcommand must have to write nothing and run no program of the user.
|
|
257
|
+
|
|
258
|
+
Each field narrows the accepted shape: options that make the command write or convert are forbidden, one of the
|
|
259
|
+
required option groups must be complete, a non-empty permitted list closes the form to anything else, the first
|
|
260
|
+
word may be restricted to a few modes, and the number of words may be capped.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
forbidden_option_set: frozenset[str] = frozenset()
|
|
264
|
+
required_option_sets: tuple[frozenset[str], ...] = ()
|
|
265
|
+
permitted_option_set: frozenset[str] = frozenset()
|
|
266
|
+
first_word_set: frozenset[str] = frozenset()
|
|
267
|
+
maximum_word_count: int | None = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# One shape shared by the three comparison subcommands: porcelain and plumbing alike run the user's external diff
|
|
271
|
+
# program and text conversion as soon as they are asked for a patch, so all three demand the same positive proof.
|
|
272
|
+
DIFF_READ_FORM = ReadForm(forbidden_option_set=frozenset({"--ext-diff", "--textconv"}),
|
|
273
|
+
required_option_sets=(frozenset({"--no-ext-diff", "--no-textconv"}), frozenset({"--name-only"}), frozenset({"--name-status"})))
|
|
274
|
+
|
|
275
|
+
# The only list of Git subcommands that read. It is closed on purpose: an unknown subcommand is not a read, because
|
|
276
|
+
# being wrong the other way would let a hook or a filter still run after the repository has been handed back. The
|
|
277
|
+
# quiet form of a comparison is not accepted either: an external diff program whose exit code is declared reliable
|
|
278
|
+
# still runs, and its answer becomes the exit code of the command.
|
|
279
|
+
READ_FORM_BY_SUBCOMMAND: dict[str, ReadForm] = {
|
|
280
|
+
"--version": ReadForm(),
|
|
281
|
+
"--exec-path": ReadForm(),
|
|
282
|
+
"check-attr": ReadForm(),
|
|
283
|
+
"check-ignore": ReadForm(),
|
|
284
|
+
"check-ref-format": ReadForm(),
|
|
285
|
+
"for-each-ref": ReadForm(),
|
|
286
|
+
"ls-files": ReadForm(),
|
|
287
|
+
"ls-tree": ReadForm(),
|
|
288
|
+
"merge-base": ReadForm(),
|
|
289
|
+
"rev-list": ReadForm(),
|
|
290
|
+
"rev-parse": ReadForm(),
|
|
291
|
+
"status": ReadForm(),
|
|
292
|
+
"var": ReadForm(),
|
|
293
|
+
"version": ReadForm(),
|
|
294
|
+
"cat-file": ReadForm(forbidden_option_set=frozenset({"--filters", "--textconv"})),
|
|
295
|
+
"fast-export": ReadForm(required_option_sets=(frozenset({"--no-data", "--no-renames", "--show-original-ids", "--use-done-feature"}),),
|
|
296
|
+
permitted_option_set=frozenset({"--no-data", "--no-renames", "--show-original-ids", "--use-done-feature", "--reencode", "--signed-commits", "--stdin"})),
|
|
297
|
+
"diff": DIFF_READ_FORM,
|
|
298
|
+
"diff-index": DIFF_READ_FORM,
|
|
299
|
+
"diff-tree": DIFF_READ_FORM,
|
|
300
|
+
# Closed by its permitted options rather than by forbidden ones: the flags that produce a patch are too many to
|
|
301
|
+
# list without missing the next one, and a patch runs the user's external diff program or text conversion.
|
|
302
|
+
"log": ReadForm(permitted_option_set=frozenset({"--no-walk", "--ignore-missing", "--no-decorate", "--no-notes", "--no-show-signature",
|
|
303
|
+
"--encoding", "--format", "--max-count", "--stdin", "-z", "--topo-order"})),
|
|
304
|
+
"show": ReadForm(forbidden_option_set=frozenset({"--ext-diff", "--textconv"}),
|
|
305
|
+
required_option_sets=(frozenset({"-s"}), frozenset({"--no-patch"}), frozenset({"--no-ext-diff", "--no-textconv"}))),
|
|
306
|
+
"hash-object": ReadForm(forbidden_option_set=frozenset({"-w"}), required_option_sets=(frozenset({"--no-filters"}),)),
|
|
307
|
+
"merge-file": ReadForm(required_option_sets=(frozenset({"-p"}), frozenset({"--stdout"}))),
|
|
308
|
+
"config": ReadForm(forbidden_option_set=frozenset({"--add", "--unset", "--unset-all", "--replace-all", "--rename-section", "--remove-section", "--edit", "-e"}),
|
|
309
|
+
required_option_sets=(frozenset({"--get"}), frozenset({"--get-all"}), frozenset({"--get-regexp"}), frozenset({"--get-urlmatch"}),
|
|
310
|
+
frozenset({"--list"}), frozenset({"-l"}))),
|
|
311
|
+
"symbolic-ref": ReadForm(forbidden_option_set=frozenset({"-d", "--delete", "-m"}), maximum_word_count=1),
|
|
312
|
+
"remote": ReadForm(first_word_set=frozenset({"get-url"})),
|
|
313
|
+
"worktree": ReadForm(first_word_set=frozenset({"list"})),
|
|
314
|
+
"reflog": ReadForm(first_word_set=frozenset({"show"})),
|
|
315
|
+
"submodule": ReadForm(first_word_set=frozenset({"status"})),
|
|
316
|
+
"ls-remote": ReadForm(required_option_sets=(frozenset({"--get-url"}),)),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
READ_SUBCOMMAND_SET = frozenset(READ_FORM_BY_SUBCOMMAND)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def is_read_command(arguments: list[str]) -> bool:
|
|
323
|
+
"""Tell whether a Git command only reads: it changes nothing and starts no program belonging to the user.
|
|
324
|
+
|
|
325
|
+
Options are read the way this program writes them, one per argument and never grouped into a single letter block.
|
|
326
|
+
An option is an argument starting with a dash and placed before the pathspec separator, compared by the name in
|
|
327
|
+
front of its first equals sign; everything else is a word, including every argument after that separator.
|
|
328
|
+
"""
|
|
329
|
+
position = git_subcommand_index(arguments)
|
|
330
|
+
if position is None:
|
|
331
|
+
return False
|
|
332
|
+
form = READ_FORM_BY_SUBCOMMAND.get(arguments[position])
|
|
333
|
+
if form is None:
|
|
334
|
+
return False
|
|
335
|
+
option_set: set[str] = set()
|
|
336
|
+
words: list[str] = []
|
|
337
|
+
after_separator = False
|
|
338
|
+
for argument in arguments[position + 1:]:
|
|
339
|
+
if not after_separator and argument == PATHSPEC_SEPARATOR:
|
|
340
|
+
after_separator = True
|
|
341
|
+
continue
|
|
342
|
+
if not after_separator and argument.startswith(OPTION_PREFIX):
|
|
343
|
+
option_name, _separator, _value = argument.partition(OPTION_VALUE_SEPARATOR)
|
|
344
|
+
option_set.add(option_name)
|
|
345
|
+
continue
|
|
346
|
+
words.append(argument)
|
|
347
|
+
if option_set & form.forbidden_option_set:
|
|
348
|
+
return False
|
|
349
|
+
if form.required_option_sets:
|
|
350
|
+
satisfied = False
|
|
351
|
+
for required_option_set in form.required_option_sets:
|
|
352
|
+
if required_option_set.issubset(option_set):
|
|
353
|
+
satisfied = True
|
|
354
|
+
break
|
|
355
|
+
if not satisfied:
|
|
356
|
+
return False
|
|
357
|
+
if form.permitted_option_set and not option_set.issubset(form.permitted_option_set):
|
|
358
|
+
return False
|
|
359
|
+
first_word_index = 0
|
|
360
|
+
if form.first_word_set and words and words[first_word_index] not in form.first_word_set:
|
|
361
|
+
return False
|
|
362
|
+
if form.maximum_word_count is None:
|
|
363
|
+
return True
|
|
364
|
+
return len(words) <= form.maximum_word_count
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass
|
|
368
|
+
class _RunningProcess:
|
|
369
|
+
"""Keep process resources alive until both termination and final draining complete."""
|
|
370
|
+
|
|
371
|
+
request: ProcessRequest
|
|
372
|
+
process: QProcess
|
|
373
|
+
deadline: QTimer
|
|
374
|
+
inactivity: QTimer
|
|
375
|
+
kill_timer: QTimer
|
|
376
|
+
group_timer: QTimer
|
|
377
|
+
group: ProcessGroup | None = None
|
|
378
|
+
stderr: bytearray = field(default_factory=bytearray)
|
|
379
|
+
stdout_size: int = 0
|
|
380
|
+
status: ProcessStatus = ProcessStatus.RUNNING
|
|
381
|
+
cause: str = ""
|
|
382
|
+
return_code: int | None = None
|
|
383
|
+
exited: bool = False
|
|
384
|
+
drain_pending: bool = False
|
|
385
|
+
stdout_paused: bool = False
|
|
386
|
+
credential_stream: SensitiveTextStream | None = None
|
|
387
|
+
ssh_stdout_stream: SensitiveTextStream | None = None
|
|
388
|
+
stderr_truncated: bool = False
|
|
389
|
+
drainage_uncertain: bool = False
|
|
390
|
+
editor_waiting: bool = False
|
|
391
|
+
editor_deadline_remaining_ms: int = 0
|
|
392
|
+
editor_idle_remaining_ms: int = 0
|
|
393
|
+
received_ms: int = 0
|
|
394
|
+
launched_ms: int = 0
|
|
395
|
+
relayed: bool = False
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class ProcessRunner(QObject):
|
|
399
|
+
"""Own queued and running QProcess objects without blocking the Qt event loop."""
|
|
400
|
+
|
|
401
|
+
stdout_chunk = Signal(object, bytes)
|
|
402
|
+
stderr_chunk = Signal(object, bytes)
|
|
403
|
+
finished = Signal(object)
|
|
404
|
+
idle = Signal()
|
|
405
|
+
stop_requested = Signal(object, str)
|
|
406
|
+
started = Signal(object)
|
|
407
|
+
|
|
408
|
+
def __init__(self, parent: QObject | None = None, global_limit: int = GLOBAL_PROCESS_LIMIT, session_limit: int = SESSION_PROCESS_LIMIT,
|
|
409
|
+
global_queue_limit: int = GLOBAL_QUEUE_LIMIT, session_queue_limit: int = SESSION_QUEUE_LIMIT, credential_consumer: Callable[[str, bytes], None] | None = None,
|
|
410
|
+
process_group_factory: Callable[[], ProcessGroup] = NativeProcessGroup, clock_ms: Callable[[], int] | None = None) -> None:
|
|
411
|
+
"""Allow small injected bounds in tests while keeping production limits centralized.
|
|
412
|
+
|
|
413
|
+
The clock is injectable too, so timing tests read exact durations instead of depending on how fast the machine is.
|
|
414
|
+
"""
|
|
415
|
+
super().__init__(parent)
|
|
416
|
+
if min(global_limit, session_limit, global_queue_limit, session_queue_limit) < 1:
|
|
417
|
+
raise ValueError("Process and queue limits must be positive")
|
|
418
|
+
self.global_limit = global_limit
|
|
419
|
+
self.session_limit = session_limit
|
|
420
|
+
self.global_queue_limit = global_queue_limit
|
|
421
|
+
self.session_queue_limit = session_queue_limit
|
|
422
|
+
self.active_by_id: dict[str, _RunningProcess] = {}
|
|
423
|
+
self.pending_requests: list[ProcessRequest] = []
|
|
424
|
+
self.closed = False
|
|
425
|
+
# Set while the queue is being emptied: a subscriber reacting to a launch cannot start a second, nested pass.
|
|
426
|
+
self.pumping = False
|
|
427
|
+
self.credential_consumer = credential_consumer
|
|
428
|
+
self.process_group_factory = process_group_factory
|
|
429
|
+
self.elapsed_timer = QElapsedTimer()
|
|
430
|
+
self.elapsed_timer.start()
|
|
431
|
+
self.clock_ms: Callable[[], int] = clock_ms if clock_ms is not None else self.elapsed_timer.elapsed
|
|
432
|
+
self.received_ms_by_id: dict[str, int] = {}
|
|
433
|
+
# Qt deletes the QProcess children after the runner itself, and a QProcess deleted while its program runs kills
|
|
434
|
+
# it, waits for it, and emits its output, error and end signals from inside that deletion. Their callbacks would
|
|
435
|
+
# then reach a runner and a QProcess whose C++ objects are already gone. Forgetting every running process when the
|
|
436
|
+
# runner goes makes each callback's ownership check turn them away; the dying QProcess still kills its program.
|
|
437
|
+
# The callback holds the dictionary, not the runner, so the runner's wrapper stays free to go with it.
|
|
438
|
+
active_by_id = self.active_by_id
|
|
439
|
+
self.destroyed.connect(lambda: active_by_id.clear())
|
|
440
|
+
|
|
441
|
+
def start(self, request: ProcessRequest) -> None:
|
|
442
|
+
"""Snapshot arguments and working directory before launching or queuing work."""
|
|
443
|
+
if request.timeout_ms <= 0 or request.idle_timeout_ms <= 0 or request.stderr_limit_bytes < 0 or (request.stdout_limit_bytes is not None and request.stdout_limit_bytes < 0):
|
|
444
|
+
raise ValueError("Invalid timeouts or output limits")
|
|
445
|
+
if request.request_id in self.active_by_id or any(pending.request_id == request.request_id for pending in self.pending_requests):
|
|
446
|
+
raise ValueError("Request ID is already active")
|
|
447
|
+
if request.profile not in ACCEPTED_PROCESS_PROFILE_SET:
|
|
448
|
+
raise ValueError("Unsupported process profile")
|
|
449
|
+
if request.stdin_filename is not None and (not os.path.isabs(request.stdin_filename) or "\0" in request.stdin_filename):
|
|
450
|
+
raise ValueError("The input file path must be absolute and contain no NUL")
|
|
451
|
+
# The exact type is required: a boolean or a plain number would otherwise pass for a level by accident.
|
|
452
|
+
if type(request.priority) is not ProcessPriority:
|
|
453
|
+
raise ValueError("Unsupported process priority")
|
|
454
|
+
if type(request.suppress_editor) is not bool or request.suppress_editor and (request.profile != LOCAL_WRITE_PROFILE or request.editor_context is not None):
|
|
455
|
+
raise ValueError("Editor suppression requires a local write without an interactive editor.")
|
|
456
|
+
editor_environment_by_name: dict[str, str] = {}
|
|
457
|
+
if request.editor_context is not None:
|
|
458
|
+
from gitpane.tool.editor import EditorExecutionContext
|
|
459
|
+
if (not isinstance(request.editor_context, EditorExecutionContext) or request.profile != LOCAL_WRITE_PROFILE
|
|
460
|
+
or not request.editor_context.owns(request.operation_id) or request.editor_context.process_request_id):
|
|
461
|
+
raise ValueError("The editor context is expired, already bound or belongs to another operation.")
|
|
462
|
+
editor_environment_by_name = request.editor_context.environment()
|
|
463
|
+
for name, value in request.environment_by_name.items():
|
|
464
|
+
editor_allowed = name in editor_environment_by_name and value == editor_environment_by_name[name]
|
|
465
|
+
# One domain-selected directory, never a caller-supplied Git search list. Git's quoting is applied only at launch.
|
|
466
|
+
alternate_allowed = (name == GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME and request.profile in {READ_PROFILE, LOCAL_WRITE_PROFILE}
|
|
467
|
+
and os.path.isabs(value) and "\0" not in value and os.path.isdir(value))
|
|
468
|
+
network_allowed = request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and value in NETWORK_ENVIRONMENT_VALUE_SET_BY_NAME.get(name, ())
|
|
469
|
+
if request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and name == "GIT_CONFIG_GLOBAL" and value == os.devnull:
|
|
470
|
+
network_allowed = True
|
|
471
|
+
if request.profile == NETWORK_PROFILE:
|
|
472
|
+
if name == "GIT_SSH":
|
|
473
|
+
network_allowed = os.path.isabs(value) and os.path.isfile(value) and os.access(value, os.X_OK)
|
|
474
|
+
elif name == "GIT_SSH_VARIANT":
|
|
475
|
+
network_allowed = value == "ssh"
|
|
476
|
+
elif name == "PATH":
|
|
477
|
+
network_allowed = os.name == "nt" and all(os.path.isabs(directory) for directory in value.split(os.pathsep) if directory)
|
|
478
|
+
elif name in {"SSH_ASKPASS", "GIT_ASKPASS"}:
|
|
479
|
+
network_allowed = os.path.isabs(value) and os.path.isfile(value) and os.access(value, os.X_OK)
|
|
480
|
+
elif name == "SSH_ASKPASS_REQUIRE":
|
|
481
|
+
network_allowed = value == "force"
|
|
482
|
+
elif name == "DISPLAY":
|
|
483
|
+
network_allowed = value == "gitpane:0"
|
|
484
|
+
elif name == "GIT_CEILING_DIRECTORIES":
|
|
485
|
+
# Only narrows where Git looks for a repository, so a command run from an empty folder reads no repository's settings.
|
|
486
|
+
network_allowed = all(os.path.isabs(directory) for directory in value.split(os.pathsep) if directory)
|
|
487
|
+
elif name in SSH_ENVIRONMENT_NAMES:
|
|
488
|
+
network_allowed = bool(request.operation_id) and len(value) <= 512 and not any(character in value for character in "\r\n")
|
|
489
|
+
if (name not in GIT_MUTATION_ENVIRONMENT_NAME_SET and not network_allowed and not editor_allowed and not alternate_allowed) or not value or "\0" in value:
|
|
490
|
+
raise ValueError("Unsupported Git process environment override")
|
|
491
|
+
if name == "GIT_INDEX_FILE" and not os.path.isabs(value):
|
|
492
|
+
raise ValueError("The candidate index path must be absolute")
|
|
493
|
+
stderr_limit = TRACE_STORAGE_LIMIT_BYTES if request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and request.stderr_limit_bytes == STDERR_LIMIT_BYTES else request.stderr_limit_bytes
|
|
494
|
+
captured = replace(request, arguments=list(request.arguments), working_dir=os.path.abspath(request.working_dir), environment_by_name=dict(request.environment_by_name), stderr_limit_bytes=stderr_limit)
|
|
495
|
+
if request.editor_context is not None:
|
|
496
|
+
captured.environment_by_name.update(editor_environment_by_name)
|
|
497
|
+
request.editor_context.process_request_id = request.request_id
|
|
498
|
+
received_ms = self.clock_ms()
|
|
499
|
+
queued_for_session = sum(pending.session_id == request.session_id for pending in self.pending_requests)
|
|
500
|
+
if self.closed or len(self.pending_requests) >= self.global_queue_limit or queued_for_session >= self.session_queue_limit:
|
|
501
|
+
status = ProcessStatus.CANCELLED if self.closed else ProcessStatus.QUEUE_FULL
|
|
502
|
+
cause = "Runner closed" if self.closed else "Process queue is full"
|
|
503
|
+
cause = self._close_editor(captured, cause)
|
|
504
|
+
QTimer.singleShot(0, self, lambda: self.finished.emit(self._timed_result(captured, status, None, cause, b"", received_ms, None)))
|
|
505
|
+
return
|
|
506
|
+
self.received_ms_by_id[captured.request_id] = received_ms
|
|
507
|
+
self.pending_requests.append(captured)
|
|
508
|
+
self._pump()
|
|
509
|
+
|
|
510
|
+
def cancel(self, request_id: str) -> None:
|
|
511
|
+
"""Abandon a queued request or stop a running one while retaining write diagnostics."""
|
|
512
|
+
for request in tuple(self.pending_requests):
|
|
513
|
+
if request.request_id == request_id:
|
|
514
|
+
self.pending_requests.remove(request)
|
|
515
|
+
received_ms = self.received_ms_by_id.pop(request.request_id, self.clock_ms())
|
|
516
|
+
cause = self._close_editor(request, request.operation + " cancelled")
|
|
517
|
+
self.finished.emit(self._timed_result(request, ProcessStatus.CANCELLED, None, cause, b"", received_ms, None))
|
|
518
|
+
self._notify_idle()
|
|
519
|
+
return
|
|
520
|
+
active = self.active_by_id.get(request_id)
|
|
521
|
+
if active is not None:
|
|
522
|
+
self._stop(active, ProcessStatus.CANCELLED, active.request.operation + " cancelled")
|
|
523
|
+
|
|
524
|
+
def protect_secret(self, request_id: str, secret: str) -> None:
|
|
525
|
+
"""Register an SSH answer before its child can echo it on either public stream."""
|
|
526
|
+
active = self.active_by_id.get(request_id)
|
|
527
|
+
if active is None or active.credential_stream is None or not secret:
|
|
528
|
+
return
|
|
529
|
+
active.credential_stream.filter.sensitive_value_set.add(secret)
|
|
530
|
+
|
|
531
|
+
def pause_stdout(self, request_id: str) -> None:
|
|
532
|
+
"""Let a consumer finish its parsing turn before draining additional stdout."""
|
|
533
|
+
active = self.active_by_id.get(request_id)
|
|
534
|
+
if active is not None:
|
|
535
|
+
active.stdout_paused = True
|
|
536
|
+
|
|
537
|
+
def resume_stdout(self, request_id: str) -> None:
|
|
538
|
+
"""Resume bounded reads after the consumer has released its parsing backlog."""
|
|
539
|
+
active = self.active_by_id.get(request_id)
|
|
540
|
+
if active is not None:
|
|
541
|
+
active.stdout_paused = False
|
|
542
|
+
self._schedule_drain(active)
|
|
543
|
+
|
|
544
|
+
def cancel_session(self, session_id: str) -> None:
|
|
545
|
+
"""Cancel only one window's work, including requests still waiting for a slot."""
|
|
546
|
+
request_ids: list[str] = []
|
|
547
|
+
for request in self.pending_requests:
|
|
548
|
+
if request.session_id == session_id:
|
|
549
|
+
request_ids.append(request.request_id)
|
|
550
|
+
for request_id, active in self.active_by_id.items():
|
|
551
|
+
if active.request.session_id == session_id:
|
|
552
|
+
request_ids.append(request_id)
|
|
553
|
+
for request_id in request_ids:
|
|
554
|
+
self.cancel(request_id)
|
|
555
|
+
|
|
556
|
+
def close(self) -> None:
|
|
557
|
+
"""Begin asynchronous shutdown and emit idle once all children have stopped."""
|
|
558
|
+
self.closed = True
|
|
559
|
+
request_ids: list[str] = []
|
|
560
|
+
for request in self.pending_requests:
|
|
561
|
+
request_ids.append(request.request_id)
|
|
562
|
+
request_ids.extend(self.active_by_id)
|
|
563
|
+
for request_id in request_ids:
|
|
564
|
+
self.cancel(request_id)
|
|
565
|
+
self._notify_idle()
|
|
566
|
+
|
|
567
|
+
def _pump(self) -> None:
|
|
568
|
+
"""Fill every free slot with the most urgent eligible request, without letting a busy session block other sessions.
|
|
569
|
+
|
|
570
|
+
The state is read again before every launch, because a launch can publish synchronously and its subscribers can
|
|
571
|
+
start, cancel or close. A call made from inside one of those publications returns at once: the pass already
|
|
572
|
+
under way sees what it changed on its next turn.
|
|
573
|
+
"""
|
|
574
|
+
if self.closed or self.pumping:
|
|
575
|
+
return
|
|
576
|
+
self.pumping = True
|
|
577
|
+
try:
|
|
578
|
+
while not self.closed and len(self.active_by_id) < self.global_limit:
|
|
579
|
+
request = self._next_eligible_request()
|
|
580
|
+
if request is None:
|
|
581
|
+
return
|
|
582
|
+
self.pending_requests.remove(request)
|
|
583
|
+
self._launch(request)
|
|
584
|
+
finally:
|
|
585
|
+
self.pumping = False
|
|
586
|
+
|
|
587
|
+
def _next_eligible_request(self) -> ProcessRequest | None:
|
|
588
|
+
"""Answer the waiting request to launch next: the most urgent level first, and the earliest arrival within a level.
|
|
589
|
+
|
|
590
|
+
Only a request whose session still has a free slot is eligible, so a saturated session never holds back another one.
|
|
591
|
+
"""
|
|
592
|
+
active_count_by_session: dict[str, int] = {}
|
|
593
|
+
for active in self.active_by_id.values():
|
|
594
|
+
active_count_by_session[active.request.session_id] = active_count_by_session.get(active.request.session_id, 0) + 1
|
|
595
|
+
selected: ProcessRequest | None = None
|
|
596
|
+
for request in self.pending_requests:
|
|
597
|
+
if active_count_by_session.get(request.session_id, 0) >= self.session_limit:
|
|
598
|
+
continue
|
|
599
|
+
if selected is None or request.priority < selected.priority:
|
|
600
|
+
selected = request
|
|
601
|
+
return selected
|
|
602
|
+
|
|
603
|
+
def _timed_result(self, request: ProcessRequest, status: ProcessStatus, return_code: int | None, cause: str, stderr: bytes, received_ms: int, launched_ms: int | None) -> ProcessResult:
|
|
604
|
+
"""Build every outcome the runner publishes, so none of them reaches a subscriber without its two timings.
|
|
605
|
+
|
|
606
|
+
A request that never left the queue has no launch instant: it waited until its own outcome and ran for no time.
|
|
607
|
+
"""
|
|
608
|
+
now_ms = self.clock_ms()
|
|
609
|
+
if launched_ms is None:
|
|
610
|
+
queue_wait_ms, duration_ms = now_ms - received_ms, 0
|
|
611
|
+
else:
|
|
612
|
+
queue_wait_ms, duration_ms = launched_ms - received_ms, now_ms - launched_ms
|
|
613
|
+
return ProcessResult(self._public_request(request), status, return_code, cause, stderr, queue_wait_ms=queue_wait_ms, duration_ms=duration_ms)
|
|
614
|
+
|
|
615
|
+
def _launch(self, request: ProcessRequest) -> None:
|
|
616
|
+
"""Start the program the request names, as a bounded child, after clearing inherited repository-specific Git overrides.
|
|
617
|
+
|
|
618
|
+
Every command, on every system, gets the command-scope setting ``core.fsmonitor=false`` through
|
|
619
|
+
``GIT_CONFIG_COUNT`` in place of the command-scope settings the user's shell passed: Git never starts its
|
|
620
|
+
fsmonitor daemon for GitPane, so no daemon can keep a supervised command's process group alive.
|
|
621
|
+
A supervised request hands that same program to its process group, which may wrap it in its own command line.
|
|
622
|
+
On macOS, a command whose program or an argument Qt would decompose is started through the exact-arguments
|
|
623
|
+
relay instead: QProcess starts the base interpreter on the relay, the exact command (the supervisor's, for a
|
|
624
|
+
supervised request) travels in the relay's variable, and the relay replaces itself with it, keeping its process
|
|
625
|
+
id. The published request and the log keep the logical command in every case.
|
|
626
|
+
"""
|
|
627
|
+
launched_ms = self.clock_ms()
|
|
628
|
+
received_ms = self.received_ms_by_id.pop(request.request_id, launched_ms)
|
|
629
|
+
group = None
|
|
630
|
+
program_filename, arguments = request.program_filename, request.arguments
|
|
631
|
+
exact_command: list[str] | None = None
|
|
632
|
+
try:
|
|
633
|
+
if request.editor_context is not None and not request.editor_context.owns(request.operation_id):
|
|
634
|
+
raise ValueError("The editor context expired before command launch.")
|
|
635
|
+
if request.before_start is not None:
|
|
636
|
+
request.before_start()
|
|
637
|
+
if is_supervised_request(request):
|
|
638
|
+
group = (request.process_group_factory or self.process_group_factory)()
|
|
639
|
+
program_filename, arguments = group.prepare(program_filename, arguments)
|
|
640
|
+
if needs_exact_arguments(sys.platform, program_filename, arguments):
|
|
641
|
+
exact_command = [program_filename, *arguments]
|
|
642
|
+
program_filename, arguments = supervisor_interpreter_filename(), [*ISOLATED_INTERPRETER_OPTIONS, exact_arguments_relay_filename()]
|
|
643
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
644
|
+
if group is not None:
|
|
645
|
+
try:
|
|
646
|
+
group.close()
|
|
647
|
+
except OSError:
|
|
648
|
+
pass
|
|
649
|
+
QTimer.singleShot(0, self, lambda cause=str(error): self._launch_failed(request, cause, received_ms, launched_ms))
|
|
650
|
+
return
|
|
651
|
+
process = QProcess(self)
|
|
652
|
+
deadline = QTimer(process)
|
|
653
|
+
inactivity = QTimer(process)
|
|
654
|
+
kill_timer = QTimer(process)
|
|
655
|
+
group_timer = QTimer(process)
|
|
656
|
+
for timer in (deadline, inactivity, kill_timer, group_timer):
|
|
657
|
+
timer.setSingleShot(True)
|
|
658
|
+
active = _RunningProcess(request, process, deadline, inactivity, kill_timer, group_timer, group=group, received_ms=received_ms, launched_ms=launched_ms,
|
|
659
|
+
relayed=exact_command is not None)
|
|
660
|
+
if request.editor_context is not None:
|
|
661
|
+
text_filter = SensitiveTextFilter(request.environment_by_name)
|
|
662
|
+
text_filter.sensitive_value_set.add(request.editor_context.token)
|
|
663
|
+
active.credential_stream = SensitiveTextStream(text_filter)
|
|
664
|
+
active.ssh_stdout_stream = SensitiveTextStream(text_filter)
|
|
665
|
+
request.editor_context.bind_peer(request.operation_id, lambda peer: self.active_by_id.get(request.request_id) is active
|
|
666
|
+
and active.status == ProcessStatus.RUNNING and active.group is not None and active.group.contains_process(peer))
|
|
667
|
+
request.editor_context.wait_changed.connect(lambda waiting: self._editor_wait_changed(active, waiting))
|
|
668
|
+
request.editor_context.failed.connect(lambda cause: self._editor_failure(active, cause))
|
|
669
|
+
elif request.profile == CREDENTIAL_PROFILE:
|
|
670
|
+
active.credential_stream = SensitiveTextStream(SensitiveTextFilter(self._credential_value_by_name(request)))
|
|
671
|
+
elif request.profile == NETWORK_PROFILE and "GITPANE_SSH_NONCE" in request.environment_by_name:
|
|
672
|
+
text_filter = SensitiveTextFilter(request.environment_by_name)
|
|
673
|
+
active.credential_stream = SensitiveTextStream(text_filter)
|
|
674
|
+
active.ssh_stdout_stream = SensitiveTextStream(text_filter)
|
|
675
|
+
self.active_by_id[request.request_id] = active
|
|
676
|
+
process.setProgram(program_filename)
|
|
677
|
+
process.setArguments(arguments)
|
|
678
|
+
process.setWorkingDirectory(request.working_dir)
|
|
679
|
+
if request.stdin_filename is not None:
|
|
680
|
+
process.setStandardInputFile(request.stdin_filename)
|
|
681
|
+
environment = QProcessEnvironment.systemEnvironment()
|
|
682
|
+
inherited_names = environment.keys()
|
|
683
|
+
for name in inherited_names:
|
|
684
|
+
if name.upper() in GIT_LOCAL_ENVIRONMENT_NAMES or name.upper().startswith(GIT_LOCAL_ENVIRONMENT_PREFIXES):
|
|
685
|
+
environment.remove(name)
|
|
686
|
+
if name.upper().startswith(TRACE_ENVIRONMENT_PREFIXES):
|
|
687
|
+
environment.remove(name)
|
|
688
|
+
if request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE} and name.upper().startswith(GCM_ENVIRONMENT_PREFIX):
|
|
689
|
+
environment.remove(name)
|
|
690
|
+
if request.profile == NETWORK_PROFILE and name.upper() in {"GIT_SSH", "GIT_SSH_COMMAND", "GIT_SSH_VARIANT", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", "GIT_ASKPASS"}:
|
|
691
|
+
environment.remove(name)
|
|
692
|
+
if name.upper().startswith(("GITPANE_SSH_", "GITPANE_EDITOR_")) or name.upper() in EDITOR_ENVIRONMENT_NAME_SET:
|
|
693
|
+
environment.remove(name)
|
|
694
|
+
environment.insert("GIT_TERMINAL_PROMPT", "0")
|
|
695
|
+
environment.insert("GIT_OPTIONAL_LOCKS", "0")
|
|
696
|
+
environment.insert("GIT_NO_LAZY_FETCH", "1")
|
|
697
|
+
environment.insert("GIT_NO_REPLACE_OBJECTS", "1")
|
|
698
|
+
environment.insert("LC_ALL", "C")
|
|
699
|
+
# The inherited count and its keys were removed above, so GitPane's setting takes index 0.
|
|
700
|
+
fsmonitor_key, fsmonitor_value = GIT_FSMONITOR_SETTING
|
|
701
|
+
environment.insert("GIT_CONFIG_COUNT", "1")
|
|
702
|
+
environment.insert("GIT_CONFIG_KEY_0", fsmonitor_key)
|
|
703
|
+
environment.insert("GIT_CONFIG_VALUE_0", fsmonitor_value)
|
|
704
|
+
for name, value in request.environment_by_name.items():
|
|
705
|
+
if name == GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME and os.pathsep in value:
|
|
706
|
+
# Git splits unquoted alternates on the platform separator. Octal C escapes preserve every filename byte;
|
|
707
|
+
# JSON's Unicode escapes are not accepted by Git's path parser.
|
|
708
|
+
quoted_parts = ['"']
|
|
709
|
+
for byte in os.fsencode(value):
|
|
710
|
+
quoted_parts.append(f"\\{byte:03o}")
|
|
711
|
+
quoted_parts.append('"')
|
|
712
|
+
value = "".join(quoted_parts)
|
|
713
|
+
environment.insert(name, value)
|
|
714
|
+
if request.suppress_editor:
|
|
715
|
+
environment.insert("GIT_EDITOR", GIT_INERT_EDITOR)
|
|
716
|
+
# Set last, so neither the inherited environment nor the request can replace the command the relay runs.
|
|
717
|
+
if exact_command is not None:
|
|
718
|
+
environment.insert(EXACT_ARGUMENTS_ENVIRONMENT_NAME, json.dumps(exact_command))
|
|
719
|
+
process.setProcessEnvironment(environment)
|
|
720
|
+
process.readyReadStandardOutput.connect(lambda: self._schedule_drain(active))
|
|
721
|
+
process.readyReadStandardError.connect(lambda: self._schedule_drain(active))
|
|
722
|
+
process.finished.connect(lambda code, status: self._exit(active, code, status))
|
|
723
|
+
process.errorOccurred.connect(lambda error: self._error(active, error))
|
|
724
|
+
deadline.timeout.connect(lambda: self._stop(active, ProcessStatus.TIMEOUT, f"Timed out ({request.timeout_ms} ms)"))
|
|
725
|
+
inactivity.timeout.connect(lambda: self._stop(active, ProcessStatus.IDLE_TIMEOUT, f"No output for {request.idle_timeout_ms} ms"))
|
|
726
|
+
kill_timer.timeout.connect(process.kill)
|
|
727
|
+
group_timer.timeout.connect(lambda: self._poll_group(active))
|
|
728
|
+
deadline.start(request.timeout_ms)
|
|
729
|
+
inactivity.start(request.idle_timeout_ms)
|
|
730
|
+
self.started.emit(self._public_request(request))
|
|
731
|
+
process.start()
|
|
732
|
+
if request.stdin is not None:
|
|
733
|
+
process.write(request.stdin)
|
|
734
|
+
process.closeWriteChannel()
|
|
735
|
+
|
|
736
|
+
def _launch_failed(self, request: ProcessRequest, cause: str, received_ms: int, launched_ms: int) -> None:
|
|
737
|
+
"""Publish setup failure without launching an untracked write as a fallback."""
|
|
738
|
+
if request.profile == CREDENTIAL_PROFILE:
|
|
739
|
+
cause = SensitiveTextFilter(self._credential_value_by_name(request)).redact(cause)
|
|
740
|
+
if request.editor_context is not None:
|
|
741
|
+
cause = SensitiveTextFilter(request.environment_by_name).redact(cause)
|
|
742
|
+
cause = self._close_editor(request, cause)
|
|
743
|
+
self.finished.emit(self._timed_result(request, ProcessStatus.START_FAILED, None, cause, b"", received_ms, launched_ms))
|
|
744
|
+
self._pump()
|
|
745
|
+
self._notify_idle()
|
|
746
|
+
|
|
747
|
+
def _close_editor(self, request: ProcessRequest, cause: str) -> str:
|
|
748
|
+
"""Attach retryable private cleanup diagnostics without changing the command's exit status."""
|
|
749
|
+
if request.editor_context is None:
|
|
750
|
+
return cause
|
|
751
|
+
cleanup_cause = request.editor_context.close()
|
|
752
|
+
if not cleanup_cause:
|
|
753
|
+
return cause
|
|
754
|
+
return cause + "; " + cleanup_cause if cause else cleanup_cause
|
|
755
|
+
|
|
756
|
+
def _editor_wait_changed(self, active: _RunningProcess, waiting: bool) -> None:
|
|
757
|
+
"""Account only live authenticated editing time outside this command's remaining budgets."""
|
|
758
|
+
context = active.request.editor_context
|
|
759
|
+
if self.active_by_id.get(active.request.request_id) is not active or active.status != ProcessStatus.RUNNING or active.exited or context is None:
|
|
760
|
+
return
|
|
761
|
+
if waiting:
|
|
762
|
+
if active.editor_waiting or not context.owns(active.request.operation_id) or context.pending_socket is None:
|
|
763
|
+
return
|
|
764
|
+
active.editor_deadline_remaining_ms = max(1, active.deadline.remainingTime())
|
|
765
|
+
active.editor_idle_remaining_ms = max(1, active.inactivity.remainingTime())
|
|
766
|
+
active.deadline.stop()
|
|
767
|
+
active.inactivity.stop()
|
|
768
|
+
active.editor_waiting = True
|
|
769
|
+
return
|
|
770
|
+
if not active.editor_waiting:
|
|
771
|
+
return
|
|
772
|
+
active.editor_waiting = False
|
|
773
|
+
active.deadline.start(active.editor_deadline_remaining_ms)
|
|
774
|
+
active.inactivity.start(active.editor_idle_remaining_ms)
|
|
775
|
+
|
|
776
|
+
def _editor_failure(self, active: _RunningProcess, cause: str) -> None:
|
|
777
|
+
"""Expose a fixed bridge diagnostic without including credentials or arbitrary editor output."""
|
|
778
|
+
if self.active_by_id.get(active.request.request_id) is not active:
|
|
779
|
+
return
|
|
780
|
+
data = (cause + "\n").encode("utf-8")
|
|
781
|
+
active.stderr.extend(data)
|
|
782
|
+
self.stderr_chunk.emit(self._public_request(active.request), data)
|
|
783
|
+
|
|
784
|
+
def _schedule_drain(self, active: _RunningProcess) -> None:
|
|
785
|
+
"""Schedule one bounded consumer turn for available process bytes."""
|
|
786
|
+
if self.active_by_id.get(active.request.request_id) is not active or active.drain_pending:
|
|
787
|
+
return
|
|
788
|
+
active.drain_pending = True
|
|
789
|
+
QTimer.singleShot(0, self, lambda: self._drain(active))
|
|
790
|
+
|
|
791
|
+
def _drain(self, active: _RunningProcess) -> None:
|
|
792
|
+
"""Read bounded chunks within a time budget, then yield back to Qt."""
|
|
793
|
+
if self.active_by_id.get(active.request.request_id) is not active:
|
|
794
|
+
return
|
|
795
|
+
active.drain_pending = False
|
|
796
|
+
if active.exited and not active.process.isOpen():
|
|
797
|
+
self._complete(active)
|
|
798
|
+
return
|
|
799
|
+
budget = QElapsedTimer()
|
|
800
|
+
budget.start()
|
|
801
|
+
while budget.elapsed() < PARSING_BUDGET_MS:
|
|
802
|
+
consumed = False
|
|
803
|
+
for channel in (QProcess.ProcessChannel.StandardOutput, QProcess.ProcessChannel.StandardError):
|
|
804
|
+
if channel == QProcess.ProcessChannel.StandardOutput and active.stdout_paused and active.status == ProcessStatus.RUNNING:
|
|
805
|
+
continue
|
|
806
|
+
active.process.setReadChannel(channel)
|
|
807
|
+
data = bytes(active.process.read(PROCESS_CHUNK_BYTES))
|
|
808
|
+
if not data:
|
|
809
|
+
continue
|
|
810
|
+
consumed = True
|
|
811
|
+
if active.status != ProcessStatus.RUNNING and active.group is None:
|
|
812
|
+
continue
|
|
813
|
+
if active.status == ProcessStatus.RUNNING and not active.editor_waiting:
|
|
814
|
+
active.inactivity.start(active.request.idle_timeout_ms)
|
|
815
|
+
if channel == QProcess.ProcessChannel.StandardOutput:
|
|
816
|
+
active.stdout_size += len(data)
|
|
817
|
+
if active.request.stdout_limit_bytes is not None and active.stdout_size > active.request.stdout_limit_bytes:
|
|
818
|
+
self._stop(active, ProcessStatus.VOLUME, f"Standard output exceeds {active.request.stdout_limit_bytes} bytes; {active.request.operation.lower()} result incomplete")
|
|
819
|
+
continue
|
|
820
|
+
if active.request.profile == CREDENTIAL_PROFILE:
|
|
821
|
+
if self.credential_consumer is not None:
|
|
822
|
+
self.credential_consumer(active.request.request_id, data)
|
|
823
|
+
elif active.ssh_stdout_stream is not None:
|
|
824
|
+
for unit in active.ssh_stdout_stream.feed(data):
|
|
825
|
+
self.stdout_chunk.emit(self._public_request(active.request), (unit + "\n").encode("utf-8"))
|
|
826
|
+
else:
|
|
827
|
+
self.stdout_chunk.emit(self._public_request(active.request), data)
|
|
828
|
+
else:
|
|
829
|
+
if active.stderr_truncated:
|
|
830
|
+
continue
|
|
831
|
+
if len(active.stderr) + len(data) > active.request.stderr_limit_bytes:
|
|
832
|
+
if active.request.profile in {NETWORK_PROFILE, CREDENTIAL_PROFILE}:
|
|
833
|
+
active.stderr_truncated = True
|
|
834
|
+
safe_data = b"Trace incomplete: standard error storage limit exceeded; remaining output withheld.\n"
|
|
835
|
+
active.stderr.extend(safe_data)
|
|
836
|
+
self.stderr_chunk.emit(self._public_request(active.request), safe_data)
|
|
837
|
+
continue
|
|
838
|
+
self._stop(active, ProcessStatus.VOLUME, f"Standard error exceeds {active.request.stderr_limit_bytes} bytes; diagnostic incomplete")
|
|
839
|
+
continue
|
|
840
|
+
if active.credential_stream is not None:
|
|
841
|
+
for piece in active.credential_stream.feed_pieces(data):
|
|
842
|
+
safe_data = piece.encode("utf-8")
|
|
843
|
+
active.stderr.extend(safe_data)
|
|
844
|
+
self.stderr_chunk.emit(self._public_request(active.request), safe_data)
|
|
845
|
+
else:
|
|
846
|
+
active.stderr.extend(data)
|
|
847
|
+
self.stderr_chunk.emit(active.request, data)
|
|
848
|
+
if not consumed:
|
|
849
|
+
if active.stdout_paused and active.status == ProcessStatus.RUNNING:
|
|
850
|
+
return
|
|
851
|
+
if active.exited:
|
|
852
|
+
self._complete(active)
|
|
853
|
+
return
|
|
854
|
+
self._schedule_drain(active)
|
|
855
|
+
|
|
856
|
+
def _stop(self, active: _RunningProcess, status: ProcessStatus, cause: str) -> None:
|
|
857
|
+
"""Retain tracked writes and their last bytes until native descendant drainage is proven."""
|
|
858
|
+
if active.status != ProcessStatus.RUNNING:
|
|
859
|
+
return
|
|
860
|
+
active.status = status
|
|
861
|
+
active.cause = cause
|
|
862
|
+
active.deadline.stop()
|
|
863
|
+
active.inactivity.stop()
|
|
864
|
+
if active.request.wait_for_exit_on_stop and active.process.state() != QProcess.ProcessState.NotRunning:
|
|
865
|
+
# Killing Git alone can orphan hooks that keep writing. Retain the native parent
|
|
866
|
+
# and reservation until Git reaps its hooks and reports its actual exit.
|
|
867
|
+
self.stop_requested.emit(self._public_request(active.request), cause)
|
|
868
|
+
return
|
|
869
|
+
if active.group is not None:
|
|
870
|
+
self.stop_requested.emit(self._public_request(active.request), cause)
|
|
871
|
+
self._poll_group(active)
|
|
872
|
+
return
|
|
873
|
+
if active.process.state() != QProcess.ProcessState.NotRunning:
|
|
874
|
+
active.process.terminate()
|
|
875
|
+
active.kill_timer.start(PROCESS_TERMINATE_TIMEOUT_MS)
|
|
876
|
+
else:
|
|
877
|
+
active.exited = True
|
|
878
|
+
self._schedule_drain(active)
|
|
879
|
+
|
|
880
|
+
def _poll_group(self, active: _RunningProcess) -> None:
|
|
881
|
+
"""Retry inconclusive native checks asynchronously without releasing the owner."""
|
|
882
|
+
if self.active_by_id.get(active.request.request_id) is not active:
|
|
883
|
+
return
|
|
884
|
+
try:
|
|
885
|
+
if active.status != ProcessStatus.RUNNING and not active.request.wait_for_exit_on_stop:
|
|
886
|
+
active.group.stop()
|
|
887
|
+
if active.exited and active.group.is_empty():
|
|
888
|
+
self._schedule_drain(active)
|
|
889
|
+
return
|
|
890
|
+
except OSError:
|
|
891
|
+
self._unknown_drainage(active)
|
|
892
|
+
active.group_timer.start(PROCESS_GROUP_POLL_MS)
|
|
893
|
+
|
|
894
|
+
def _unknown_drainage(self, active: _RunningProcess) -> None:
|
|
895
|
+
"""Report uncertainty once while preserving the process slot and repository owner."""
|
|
896
|
+
if active.drainage_uncertain:
|
|
897
|
+
return
|
|
898
|
+
active.drainage_uncertain = True
|
|
899
|
+
cause = "Stopping: outcome unknown; descendant termination could not be confirmed"
|
|
900
|
+
active.cause = active.cause + "; " + cause if active.cause else cause
|
|
901
|
+
self.stop_requested.emit(self._public_request(active.request), cause)
|
|
902
|
+
|
|
903
|
+
def _error(self, active: _RunningProcess, error: QProcess.ProcessError) -> None:
|
|
904
|
+
if self.active_by_id.get(active.request.request_id) is not active:
|
|
905
|
+
return
|
|
906
|
+
if active.status == ProcessStatus.RUNNING:
|
|
907
|
+
active.cause = active.process.errorString()
|
|
908
|
+
if error == QProcess.ProcessError.FailedToStart:
|
|
909
|
+
active.status = ProcessStatus.START_FAILED
|
|
910
|
+
active.exited = True
|
|
911
|
+
self._schedule_drain(active)
|
|
912
|
+
|
|
913
|
+
def _exit(self, active: _RunningProcess, return_code: int, exit_status: QProcess.ExitStatus) -> None:
|
|
914
|
+
"""Drain the supervisor's streams without treating its exit as descendant proof."""
|
|
915
|
+
if self.active_by_id.get(active.request.request_id) is not active:
|
|
916
|
+
return
|
|
917
|
+
active.return_code = return_code
|
|
918
|
+
active.exited = True
|
|
919
|
+
active.deadline.stop()
|
|
920
|
+
active.inactivity.stop()
|
|
921
|
+
active.kill_timer.stop()
|
|
922
|
+
if exit_status == QProcess.ExitStatus.CrashExit and not active.cause:
|
|
923
|
+
active.cause = active.process.errorString()
|
|
924
|
+
self._schedule_drain(active)
|
|
925
|
+
|
|
926
|
+
def _complete(self, active: _RunningProcess) -> None:
|
|
927
|
+
"""Publish a final result only after draining and protecting credential output.
|
|
928
|
+
|
|
929
|
+
A relay that refused to start its command is recognised first: for a supervised request the command it could not
|
|
930
|
+
start is the supervisor, so no group was ever created and there is nothing to wait for before freeing the request.
|
|
931
|
+
"""
|
|
932
|
+
relay_cause = relay_start_failure_cause(active.relayed, active.return_code, bytes(active.stderr)) if active.status == ProcessStatus.RUNNING else None
|
|
933
|
+
if relay_cause is not None:
|
|
934
|
+
# The refusal line becomes the cause, as for a program the system could not start; it is not the command's output.
|
|
935
|
+
active.status = ProcessStatus.START_FAILED
|
|
936
|
+
active.return_code = None
|
|
937
|
+
active.cause = relay_cause
|
|
938
|
+
active.stderr.clear()
|
|
939
|
+
if active.group is not None and active.status != ProcessStatus.START_FAILED:
|
|
940
|
+
try:
|
|
941
|
+
if not active.group.is_empty():
|
|
942
|
+
self._unknown_drainage(active)
|
|
943
|
+
active.group_timer.start(PROCESS_GROUP_POLL_MS)
|
|
944
|
+
return
|
|
945
|
+
if active.group.launch_failed() and active.status == ProcessStatus.RUNNING:
|
|
946
|
+
active.status = ProcessStatus.START_FAILED
|
|
947
|
+
active.cause = bytes(active.stderr).decode("utf-8", "replace").strip() or "The supervised command could not be started"
|
|
948
|
+
active.return_code = active.group.result_return_code(active.return_code)
|
|
949
|
+
except OSError:
|
|
950
|
+
self._unknown_drainage(active)
|
|
951
|
+
active.group_timer.start(PROCESS_GROUP_POLL_MS)
|
|
952
|
+
return
|
|
953
|
+
if self.active_by_id.pop(active.request.request_id, None) is not active:
|
|
954
|
+
return
|
|
955
|
+
for timer in (active.deadline, active.inactivity, active.kill_timer, active.group_timer):
|
|
956
|
+
timer.stop()
|
|
957
|
+
timer.timeout.disconnect()
|
|
958
|
+
# Release callbacks before deferred destruction: each closure otherwise retains the
|
|
959
|
+
# process and its timers through a Python/Qt reference cycle.
|
|
960
|
+
active.process.readyReadStandardOutput.disconnect()
|
|
961
|
+
active.process.readyReadStandardError.disconnect()
|
|
962
|
+
active.process.finished.disconnect()
|
|
963
|
+
active.process.errorOccurred.disconnect()
|
|
964
|
+
if active.status == ProcessStatus.RUNNING:
|
|
965
|
+
active.status = ProcessStatus.SUCCESS if active.return_code == 0 and not active.cause else ProcessStatus.FAILED
|
|
966
|
+
if active.status == ProcessStatus.FAILED and not active.cause:
|
|
967
|
+
active.cause = bytes(active.stderr).decode("utf-8", "replace").strip() or EXIT_CODE_CAUSE_TEMPLATE.format(return_code=active.return_code)
|
|
968
|
+
if active.credential_stream is not None and not active.stderr_truncated:
|
|
969
|
+
for piece in active.credential_stream.feed_pieces(b"", final=True):
|
|
970
|
+
safe_data = piece.encode("utf-8")
|
|
971
|
+
active.stderr.extend(safe_data)
|
|
972
|
+
self.stderr_chunk.emit(self._public_request(active.request), safe_data)
|
|
973
|
+
stderr = bytes(active.stderr)
|
|
974
|
+
cause = self._close_editor(active.request, active.cause)
|
|
975
|
+
if active.credential_stream is not None:
|
|
976
|
+
cause = active.credential_stream.filter.redact(cause)
|
|
977
|
+
if active.ssh_stdout_stream is not None:
|
|
978
|
+
for unit in active.ssh_stdout_stream.feed(b"", final=True):
|
|
979
|
+
self.stdout_chunk.emit(self._public_request(active.request), (unit + "\n").encode("utf-8"))
|
|
980
|
+
if active.group is not None:
|
|
981
|
+
try:
|
|
982
|
+
active.group.close()
|
|
983
|
+
except OSError:
|
|
984
|
+
# Drainage is already proven; an antivirus or file permission race during
|
|
985
|
+
# private-marker cleanup must not lose the terminal repository result.
|
|
986
|
+
cleanup_cause = "Process finished, but private supervisor files could not be removed"
|
|
987
|
+
cause = cause + "; " + cleanup_cause if cause else cleanup_cause
|
|
988
|
+
self.finished.emit(self._timed_result(active.request, active.status, active.return_code, cause, stderr, active.received_ms, active.launched_ms))
|
|
989
|
+
active.process.deleteLater()
|
|
990
|
+
self._pump()
|
|
991
|
+
self._notify_idle()
|
|
992
|
+
|
|
993
|
+
def _public_request(self, request: ProcessRequest) -> ProcessRequest:
|
|
994
|
+
"""Keep the credential protocol input out of publicly emitted request metadata."""
|
|
995
|
+
if request.before_start is not None:
|
|
996
|
+
request = replace(request, before_start=None)
|
|
997
|
+
if request.editor_context is not None:
|
|
998
|
+
environment_by_name = dict(request.environment_by_name)
|
|
999
|
+
for name in EDITOR_ENVIRONMENT_NAME_SET:
|
|
1000
|
+
environment_by_name.pop(name, None)
|
|
1001
|
+
request = replace(request, editor_context=None, environment_by_name=environment_by_name)
|
|
1002
|
+
if any(name in request.environment_by_name for name in SSH_ENVIRONMENT_NAMES):
|
|
1003
|
+
environment_by_name = dict(request.environment_by_name)
|
|
1004
|
+
for name in SSH_ENVIRONMENT_NAMES:
|
|
1005
|
+
environment_by_name.pop(name, None)
|
|
1006
|
+
request = replace(request, environment_by_name=environment_by_name)
|
|
1007
|
+
if request.profile == CREDENTIAL_PROFILE:
|
|
1008
|
+
arguments: list[str] = []
|
|
1009
|
+
text_filter = SensitiveTextFilter(self._credential_value_by_name(request))
|
|
1010
|
+
masked_arguments = mask_sensitive_arguments(request.arguments).arguments
|
|
1011
|
+
for argument in masked_arguments:
|
|
1012
|
+
arguments.append(argument if argument == MASKED_VALUE else text_filter.redact(argument))
|
|
1013
|
+
return replace(request, stdin=None, arguments=arguments)
|
|
1014
|
+
return request
|
|
1015
|
+
|
|
1016
|
+
def _credential_value_by_name(self, request: ProcessRequest) -> dict[str, str]:
|
|
1017
|
+
"""Hide protocol and argument secrets even if a helper repeats them in its trace."""
|
|
1018
|
+
value_by_name = dict(os.environ)
|
|
1019
|
+
# Only the secrets: a setting login stays readable in the helper's trace, like the URLs it is part of.
|
|
1020
|
+
secret_values = mask_sensitive_arguments(request.arguments).secret_values
|
|
1021
|
+
# The name, not the number, is what matters: the text filter protects every value stored under a secret-looking name.
|
|
1022
|
+
for position, hidden_value in enumerate(secret_values):
|
|
1023
|
+
value_by_name[f"credential_argument_{position}"] = hidden_value
|
|
1024
|
+
if request.stdin:
|
|
1025
|
+
for line in request.stdin.decode("utf-8", "replace").splitlines():
|
|
1026
|
+
name, separator, value = line.partition("=")
|
|
1027
|
+
if separator and SENSITIVE_NAME_PATTERN.search(name):
|
|
1028
|
+
value_by_name[name] = value
|
|
1029
|
+
return value_by_name
|
|
1030
|
+
|
|
1031
|
+
def _notify_idle(self) -> None:
|
|
1032
|
+
if not self.active_by_id and not self.pending_requests:
|
|
1033
|
+
self.idle.emit()
|