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,851 @@
|
|
|
1
|
+
"""Keep a native process group alive, inherited streams included, beyond the exit of its parent.
|
|
2
|
+
|
|
3
|
+
This file is also run on its own, as the supervisor started before every supervised command: the base interpreter
|
|
4
|
+
runs it in isolated mode, with neither the application's packages nor any site directory. At that point only the
|
|
5
|
+
standard library can be imported, so what the parent side alone needs is imported where the parent side uses it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import enum
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from ctypes import wintypes
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1
|
|
23
|
+
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
|
24
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
25
|
+
# A member may start a process outside the job, but only by asking for it explicitly when it starts that process.
|
|
26
|
+
JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
|
|
27
|
+
# Every process a member starts is outside the job, without asking.
|
|
28
|
+
JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK = 0x1000
|
|
29
|
+
JOB_OBJECT_ASSIGN_PROCESS_AND_QUERY = 0x0005
|
|
30
|
+
CREATE_SUSPENDED = 0x00000004
|
|
31
|
+
CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
|
32
|
+
# Rights needed to put a process in a job (set quota, terminate) and to let it run afterwards (suspend and resume).
|
|
33
|
+
PROCESS_JOB_ADOPTION_ACCESS = 0x0100 | 0x0001 | 0x0800
|
|
34
|
+
# The least right that lets a caller ask which job holds a process.
|
|
35
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
36
|
+
NT_SUCCESS_STATUS = 0
|
|
37
|
+
# Shows a negative status the way Windows documents it, as an unsigned 32-bit value.
|
|
38
|
+
NT_STATUS_MASK = 0xFFFFFFFF
|
|
39
|
+
PROCESS_STOP_EXIT_CODE = 125
|
|
40
|
+
PROCESS_START_FAILED_EXIT_CODE = 127
|
|
41
|
+
LINUX_SET_CHILD_SUBREAPER = 36
|
|
42
|
+
LINUX_SET_PARENT_DEATH_SIGNAL = 1
|
|
43
|
+
SUPERVISOR_ARGUMENT_OFFSET = 1
|
|
44
|
+
# Settings the supervisor reads before its target program: control folder, job name, poll period and GitPane's process ID.
|
|
45
|
+
SUPERVISOR_SETTING_ARGUMENT_COUNT = 4
|
|
46
|
+
SUPERVISOR_PROCESS_COUNT = 1
|
|
47
|
+
IO_COUNTER_FIELD_COUNT = 6
|
|
48
|
+
MINIMUM_TRACKED_PROCESS_ID = 2
|
|
49
|
+
# Workaround: a copy of config.py's value. The supervisor runs this file in isolated mode, where it cannot import the
|
|
50
|
+
# application package, so it keeps its own; a test checks that both stay equal.
|
|
51
|
+
MILLISECONDS_PER_SECOND = 1000
|
|
52
|
+
# Workaround: copies of config.py's platform names, for the same reason as above; a test checks that they stay equal.
|
|
53
|
+
MACOS_PLATFORM = "darwin"
|
|
54
|
+
LINUX_PLATFORM = "linux"
|
|
55
|
+
WINDOWS_PLATFORM = "win32"
|
|
56
|
+
# Systems where the supervisor stops its command itself when GitPane dies, compared as prefixes of sys.platform.
|
|
57
|
+
OWNER_WATCH_PLATFORM_PREFIXES = (MACOS_PLATFORM, LINUX_PLATFORM)
|
|
58
|
+
CONSOLE_INTERPRETER_BASENAME = "python.exe"
|
|
59
|
+
WINDOWLESS_INTERPRETER_BASENAME = "pythonw.exe"
|
|
60
|
+
STOP_BASENAME = "stop"
|
|
61
|
+
GROUP_BASENAME = "group"
|
|
62
|
+
COMPLETE_BASENAME = "complete"
|
|
63
|
+
START_FAILED_BASENAME = "start-failed"
|
|
64
|
+
PROC_DIR = "/proc"
|
|
65
|
+
PROC_STAT_BASENAME = "stat"
|
|
66
|
+
# Fields of a /proc/<pid>/stat line counted from the first one after the command name.
|
|
67
|
+
PROC_STAT_STATE_INDEX = 0
|
|
68
|
+
PROC_STAT_PARENT_INDEX = 1
|
|
69
|
+
PROC_STAT_GROUP_INDEX = 2
|
|
70
|
+
PROC_STAT_SESSION_INDEX = 3
|
|
71
|
+
ZOMBIE_STATE = "Z"
|
|
72
|
+
# Windows signatures of the kernel32 functions this module calls, declared once so ctypes never truncates a handle or a pointer on 64 bits.
|
|
73
|
+
KERNEL_SIGNATURE_BY_NAME = {
|
|
74
|
+
"CreateJobObjectW": ([ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
|
|
75
|
+
"OpenJobObjectW": ([wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR], wintypes.HANDLE),
|
|
76
|
+
"SetInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
|
|
77
|
+
"QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL),
|
|
78
|
+
"AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
|
|
79
|
+
"TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
|
|
80
|
+
"GetCurrentProcess": ([], wintypes.HANDLE),
|
|
81
|
+
"OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
|
|
82
|
+
"IsProcessInJob": ([wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)], wintypes.BOOL),
|
|
83
|
+
"CloseHandle": ([wintypes.HANDLE], wintypes.BOOL),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bound_kernel(function_names: tuple[str, ...]) -> ctypes.WinDLL:
|
|
88
|
+
"""Load kernel32 and give each requested function its signature from ``KERNEL_SIGNATURE_BY_NAME``.
|
|
89
|
+
|
|
90
|
+
Every caller gets its own instance, so none depends on the declarations another one made. Only the standard library
|
|
91
|
+
is used, since the supervisor runs this file without the application's packages.
|
|
92
|
+
"""
|
|
93
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
94
|
+
for function_name in function_names:
|
|
95
|
+
argument_types, result_type = KERNEL_SIGNATURE_BY_NAME[function_name]
|
|
96
|
+
function = getattr(kernel, function_name)
|
|
97
|
+
function.argtypes = argument_types
|
|
98
|
+
function.restype = result_type
|
|
99
|
+
return kernel
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _JobBasicLimit(ctypes.Structure):
|
|
103
|
+
"""Match the native limit structure without assuming pointer or integer widths."""
|
|
104
|
+
|
|
105
|
+
_fields_ = [("per_process_time", ctypes.c_longlong), ("per_job_time", ctypes.c_longlong), ("limit_flags", wintypes.DWORD),
|
|
106
|
+
("minimum_working_set", ctypes.c_size_t), ("maximum_working_set", ctypes.c_size_t), ("active_process_limit", wintypes.DWORD),
|
|
107
|
+
("affinity", ctypes.c_size_t), ("priority_class", wintypes.DWORD), ("scheduling_class", wintypes.DWORD)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _JobExtendedLimit(ctypes.Structure):
|
|
111
|
+
"""Include native accounting fields to place the kill-on-close flag correctly."""
|
|
112
|
+
|
|
113
|
+
_fields_ = [("basic_limit", _JobBasicLimit), ("io_counter", ctypes.c_ulonglong * IO_COUNTER_FIELD_COUNT), ("process_memory_limit", ctypes.c_size_t),
|
|
114
|
+
("job_memory_limit", ctypes.c_size_t), ("peak_process_memory", ctypes.c_size_t), ("peak_job_memory", ctypes.c_size_t)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _JobAccounting(ctypes.Structure):
|
|
118
|
+
"""Read the OS count that includes descendants after the original process exits."""
|
|
119
|
+
|
|
120
|
+
_fields_ = [("total_user_time", ctypes.c_longlong), ("total_kernel_time", ctypes.c_longlong), ("period_user_time", ctypes.c_longlong),
|
|
121
|
+
("period_kernel_time", ctypes.c_longlong), ("page_fault_count", wintypes.DWORD), ("total_process_count", wintypes.DWORD),
|
|
122
|
+
("active_process_count", wintypes.DWORD), ("terminated_process_count", wintypes.DWORD)]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _WindowsJob:
|
|
126
|
+
"""Own a Windows job whose members cannot leave while their helpers are running."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, name: str | None, *, existing: bool = False, limit_flags: int = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) -> None:
|
|
129
|
+
"""Bind the pointer-safe APIs before creating or opening the named job; a job without a name is private to its creator.
|
|
130
|
+
|
|
131
|
+
A created job always stops its members when its last handle closes; ``limit_flags`` may add what its members are
|
|
132
|
+
allowed to start outside of it.
|
|
133
|
+
"""
|
|
134
|
+
self.kernel = _bound_kernel(("CreateJobObjectW", "OpenJobObjectW", "SetInformationJobObject", "QueryInformationJobObject", "AssignProcessToJobObject",
|
|
135
|
+
"TerminateJobObject", "GetCurrentProcess", "OpenProcess", "IsProcessInJob", "CloseHandle"))
|
|
136
|
+
self.handle = self.kernel.OpenJobObjectW(JOB_OBJECT_ASSIGN_PROCESS_AND_QUERY, False, name) if existing else self.kernel.CreateJobObjectW(None, name)
|
|
137
|
+
if not self.handle:
|
|
138
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
139
|
+
if existing:
|
|
140
|
+
return
|
|
141
|
+
limit = _JobExtendedLimit()
|
|
142
|
+
limit.basic_limit.limit_flags = limit_flags | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
143
|
+
if not self.kernel.SetInformationJobObject(self.handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(limit), ctypes.sizeof(limit)):
|
|
144
|
+
error = ctypes.WinError(ctypes.get_last_error())
|
|
145
|
+
self.close()
|
|
146
|
+
raise error
|
|
147
|
+
|
|
148
|
+
def adopt_suspended(self, process_id: int) -> None:
|
|
149
|
+
"""Put a process started suspended in this job, then let it run, so it can start nothing before it is a member."""
|
|
150
|
+
process_handle = self.kernel.OpenProcess(PROCESS_JOB_ADOPTION_ACCESS, False, process_id)
|
|
151
|
+
if not process_handle:
|
|
152
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
153
|
+
try:
|
|
154
|
+
if not self.kernel.AssignProcessToJobObject(self.handle, process_handle):
|
|
155
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
156
|
+
# Workaround: subprocess does not keep the handle of the main thread of a process it started, so the thread
|
|
157
|
+
# cannot be resumed with the documented ResumeThread. NtResumeProcess resumes every thread of the process at
|
|
158
|
+
# once; it is exported by ntdll and has kept the same behaviour since Windows XP.
|
|
159
|
+
ntdll = ctypes.WinDLL("ntdll")
|
|
160
|
+
ntdll.NtResumeProcess.argtypes = [wintypes.HANDLE]
|
|
161
|
+
ntdll.NtResumeProcess.restype = ctypes.c_long
|
|
162
|
+
status = ntdll.NtResumeProcess(process_handle)
|
|
163
|
+
if status != NT_SUCCESS_STATUS:
|
|
164
|
+
raise OSError(f"The process could not be resumed (status {status & NT_STATUS_MASK:#010x})")
|
|
165
|
+
finally:
|
|
166
|
+
self.kernel.CloseHandle(process_handle)
|
|
167
|
+
|
|
168
|
+
def join_current_process(self) -> None:
|
|
169
|
+
"""Join before launching user commands so even their first child inherits membership."""
|
|
170
|
+
if not self.kernel.AssignProcessToJobObject(self.handle, self.kernel.GetCurrentProcess()):
|
|
171
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
172
|
+
# Only the GUI retains a handle: its unexpected exit must also kill this job.
|
|
173
|
+
# Queries with a null handle inspect the calling process's immediate job.
|
|
174
|
+
self.close()
|
|
175
|
+
|
|
176
|
+
def active_count(self) -> int:
|
|
177
|
+
"""Ask the kernel for live membership, including nested descendant jobs."""
|
|
178
|
+
accounting = _JobAccounting()
|
|
179
|
+
if not self.kernel.QueryInformationJobObject(self.handle, JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, ctypes.byref(accounting), ctypes.sizeof(accounting), None):
|
|
180
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
181
|
+
return accounting.active_process_count
|
|
182
|
+
|
|
183
|
+
def stop(self) -> None:
|
|
184
|
+
"""Terminate every current member rather than only the observed parent."""
|
|
185
|
+
if not self.kernel.TerminateJobObject(self.handle, PROCESS_STOP_EXIT_CODE):
|
|
186
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
"""Release the native handle exactly once after membership is drained."""
|
|
190
|
+
if self.handle:
|
|
191
|
+
self.kernel.CloseHandle(self.handle)
|
|
192
|
+
self.handle = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ProcessGroup(Protocol):
|
|
196
|
+
"""Provide launch isolation and verifiable drainage for replaceable native controllers."""
|
|
197
|
+
|
|
198
|
+
def prepare(self, program_filename: str, arguments: list[str]) -> tuple[str, list[str]]:
|
|
199
|
+
"""Return a supervised command without changing its input and output channels."""
|
|
200
|
+
...
|
|
201
|
+
|
|
202
|
+
def contains_process(self, process_id: int) -> bool:
|
|
203
|
+
"""Return whether the OS proves this process belongs to the tracked descendants."""
|
|
204
|
+
...
|
|
205
|
+
|
|
206
|
+
def stop(self) -> None:
|
|
207
|
+
"""Request descendant termination while retaining proof resources."""
|
|
208
|
+
...
|
|
209
|
+
|
|
210
|
+
def is_empty(self) -> bool:
|
|
211
|
+
"""Return true only when no descendant can continue modifying repository data."""
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
def launch_failed(self) -> bool:
|
|
215
|
+
"""Distinguish a failed target launch from the supervisor's own exit."""
|
|
216
|
+
...
|
|
217
|
+
|
|
218
|
+
def result_return_code(self, fallback: int | None) -> int | None:
|
|
219
|
+
"""Preserve the target's signed native status across the supervisor boundary."""
|
|
220
|
+
...
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
"""Release resources after a terminal result becomes safe to publish."""
|
|
224
|
+
...
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def watches_owner_exit(platform: str) -> bool:
|
|
228
|
+
"""Tell whether the supervisor must watch GitPane itself to stop its command when GitPane dies.
|
|
229
|
+
|
|
230
|
+
On Windows the supervisor is a member of GitPane's job, which the kernel stops as soon as GitPane exits. macOS has
|
|
231
|
+
no such job and nothing adopts orphans there: the supervisor's parent simply becomes launchd. Linux gives the
|
|
232
|
+
supervisor no parent-death signal either. On both, the supervisor reads its parent at start and stops the command's
|
|
233
|
+
group as soon as that parent changes.
|
|
234
|
+
"""
|
|
235
|
+
return platform.startswith(OWNER_WATCH_PLATFORM_PREFIXES)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ReleasedChildGuard(enum.Enum):
|
|
239
|
+
"""How a system keeps a released helper from outliving the worker that started it."""
|
|
240
|
+
|
|
241
|
+
# A private job, which the kernel stops when its owner exits or when it is closed.
|
|
242
|
+
JOB = enum.auto()
|
|
243
|
+
# The kernel's parent-death signal, which the helper asks for before its program runs.
|
|
244
|
+
PARENT_DEATH_SIGNAL = enum.auto()
|
|
245
|
+
# A small watcher started as the helper's parent, which the kernel tells when the worker ends.
|
|
246
|
+
WATCHER = enum.auto()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Compared as prefixes of sys.platform; a system missing here has no known guard and is refused.
|
|
250
|
+
RELEASED_CHILD_GUARD_BY_PLATFORM_PREFIX = {
|
|
251
|
+
WINDOWS_PLATFORM: ReleasedChildGuard.JOB,
|
|
252
|
+
LINUX_PLATFORM: ReleasedChildGuard.PARENT_DEATH_SIGNAL,
|
|
253
|
+
MACOS_PLATFORM: ReleasedChildGuard.WATCHER,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def released_child_guard(platform: str) -> ReleasedChildGuard:
|
|
258
|
+
"""Pick the way ``platform`` stops a released helper together with its worker, refusing a system without one.
|
|
259
|
+
|
|
260
|
+
Windows holds the helper in a private job. Linux lets the helper ask the kernel for a signal when its parent dies.
|
|
261
|
+
macOS has neither, and stopping the worker's group does not reach a helper that leads a session of its own, so a
|
|
262
|
+
watcher becomes the helper's parent and stops it when the kernel reports the worker's end.
|
|
263
|
+
"""
|
|
264
|
+
for platform_prefix, guard in RELEASED_CHILD_GUARD_BY_PLATFORM_PREFIX.items():
|
|
265
|
+
if platform.startswith(platform_prefix):
|
|
266
|
+
return guard
|
|
267
|
+
raise OSError("No way to stop a released helper with its worker is known on " + platform)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def released_child_watch_filename() -> str:
|
|
271
|
+
"""Answer the absolute file of the released helper watcher, as imported from the installation; a module that cannot be found or is not valid Python, or a missing file, is a launch error."""
|
|
272
|
+
try:
|
|
273
|
+
import gitpane.process.released_child_watch
|
|
274
|
+
except (ImportError, SyntaxError) as error:
|
|
275
|
+
raise OSError("The released helper watcher is not available: " + str(error)) from error
|
|
276
|
+
|
|
277
|
+
watch_filename = os.path.abspath(gitpane.process.released_child_watch.__file__)
|
|
278
|
+
if not os.path.isfile(watch_filename):
|
|
279
|
+
raise OSError("The released helper watcher is not available: " + watch_filename)
|
|
280
|
+
return watch_filename
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def supervisor_script_filename() -> str:
|
|
284
|
+
"""Answer the absolute file of the supervisor script, this module as imported from the installation; a missing file is a launch error."""
|
|
285
|
+
script_filename = os.path.abspath(__file__)
|
|
286
|
+
if not os.path.isfile(script_filename):
|
|
287
|
+
raise OSError("The supervisor script is not available: " + script_filename)
|
|
288
|
+
return script_filename
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def console_interpreter_filename(interpreter_filename: str) -> str:
|
|
292
|
+
"""Answer the console form of ``interpreter_filename``: the console interpreter of the same folder for the Windows windowless one, any other name unchanged.
|
|
293
|
+
|
|
294
|
+
Windows gives no console to a windowless program, even when asked to start it with a hidden one, and every console
|
|
295
|
+
program such a process starts then opens a visible console window of its own. A Python helper that starts console
|
|
296
|
+
programs (Git, the credential manager) therefore runs on the console interpreter, which receives the hidden console
|
|
297
|
+
and hands it on. Only the exact Windows name is replaced, in any case, so Linux and macOS interpreters never change.
|
|
298
|
+
The answer is a name only: whether that file exists is for the caller to check.
|
|
299
|
+
"""
|
|
300
|
+
directory, basename = os.path.split(interpreter_filename)
|
|
301
|
+
if basename.casefold() != WINDOWLESS_INTERPRETER_BASENAME:
|
|
302
|
+
return interpreter_filename
|
|
303
|
+
return os.path.join(directory, CONSOLE_INTERPRETER_BASENAME)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def supervisor_interpreter_filename() -> str:
|
|
307
|
+
"""Answer the interpreter that runs the supervisor: the base interpreter of this runtime, in its console form.
|
|
308
|
+
|
|
309
|
+
The interpreter of a virtual environment is a small launcher that starts the base interpreter as one more process;
|
|
310
|
+
starting the base interpreter directly saves that process for every supervised command, and the supervisor needs
|
|
311
|
+
nothing of the environment. The runtime names its base interpreter; when it names none, the running interpreter is
|
|
312
|
+
the explicit fallback. An application started without a console runs the windowless interpreter, which cannot hand
|
|
313
|
+
its standard streams on to Git, so the console interpreter installed beside it is taken instead. A named
|
|
314
|
+
interpreter that is missing is a launch error: another Python is never searched for.
|
|
315
|
+
"""
|
|
316
|
+
# The base interpreter is only published as a private attribute of the runtime; nothing public names it.
|
|
317
|
+
candidate = console_interpreter_filename(getattr(sys, "_base_executable", None) or sys.executable)
|
|
318
|
+
if not os.path.isabs(candidate) or not os.path.isfile(candidate):
|
|
319
|
+
raise OSError("The supervisor interpreter is not available: " + candidate)
|
|
320
|
+
return candidate
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def supervisor_poll_ms(value: str) -> int:
|
|
324
|
+
"""Read the poll period the parent hands to the supervisor, refusing anything but a positive number of milliseconds."""
|
|
325
|
+
poll_ms = int(value)
|
|
326
|
+
if poll_ms <= 0:
|
|
327
|
+
raise ValueError("The supervisor poll period must be positive")
|
|
328
|
+
return poll_ms
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def supervisor_owner_id(value: str) -> int:
|
|
332
|
+
"""Read the process ID of GitPane, which the parent hands to the supervisor, refusing anything but a positive number."""
|
|
333
|
+
owner_id = int(value)
|
|
334
|
+
if owner_id <= 0:
|
|
335
|
+
raise ValueError("The supervisor owner process ID must be positive")
|
|
336
|
+
return owner_id
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class NativeProcessGroup:
|
|
340
|
+
"""Coordinate an OS job or session through private, non-secret control markers."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, *, breakaway_allowed: bool = False) -> None:
|
|
343
|
+
"""Create private control storage before any process can be launched.
|
|
344
|
+
|
|
345
|
+
``breakaway_allowed`` lets a member start a process outside the group when it explicitly asks for it; nothing
|
|
346
|
+
leaves the group otherwise. Only a browser sign-in worker needs it, to hold Git Credential Manager in a job of its own.
|
|
347
|
+
"""
|
|
348
|
+
import tempfile
|
|
349
|
+
import uuid
|
|
350
|
+
|
|
351
|
+
self.control = tempfile.TemporaryDirectory(prefix="gitpane-process-")
|
|
352
|
+
self.control_dir = self.control.name
|
|
353
|
+
self.job_name = "Local\\gitpane-" + uuid.uuid4().hex if os.name == "nt" else ""
|
|
354
|
+
limit_flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | (JOB_OBJECT_LIMIT_BREAKAWAY_OK if breakaway_allowed else 0)
|
|
355
|
+
try:
|
|
356
|
+
self.job = _WindowsJob(self.job_name, limit_flags=limit_flags) if os.name == "nt" else None
|
|
357
|
+
except OSError:
|
|
358
|
+
self.control.cleanup()
|
|
359
|
+
raise
|
|
360
|
+
|
|
361
|
+
def prepare(self, program_filename: str, arguments: list[str]) -> tuple[str, list[str]]:
|
|
362
|
+
"""Start the standard-library bootstrap, which joins the group before it runs the target unchanged.
|
|
363
|
+
|
|
364
|
+
A repository can contain a hostile gitpane package, a sitecustomize module or a PYTHONPATH: isolated mode
|
|
365
|
+
ignores the environment and the current directory, and skipping site loads nothing from any site directory.
|
|
366
|
+
The script is the absolute file of this module, as imported from the installation. The poll period comes from
|
|
367
|
+
the shared configuration, which the bootstrap itself cannot import. The process ID of the process preparing the
|
|
368
|
+
command, GitPane, which starts the supervisor itself, tells the supervisor which parent to watch.
|
|
369
|
+
"""
|
|
370
|
+
from gitpane.config import ISOLATED_INTERPRETER_OPTIONS, PROCESS_GROUP_POLL_MS
|
|
371
|
+
|
|
372
|
+
return supervisor_interpreter_filename(), [*ISOLATED_INTERPRETER_OPTIONS, supervisor_script_filename(), self.control_dir, self.job_name, str(PROCESS_GROUP_POLL_MS),
|
|
373
|
+
str(os.getpid()), program_filename, *arguments]
|
|
374
|
+
|
|
375
|
+
def stop(self) -> None:
|
|
376
|
+
"""Cancel startup as well as existing members without freeing their reservation."""
|
|
377
|
+
_write_marker(self.control_dir, STOP_BASENAME)
|
|
378
|
+
if self.job is not None:
|
|
379
|
+
self.job.stop()
|
|
380
|
+
return
|
|
381
|
+
group_id = _read_group_id(self.control_dir)
|
|
382
|
+
if group_id is not None:
|
|
383
|
+
_stop_posix_group(group_id)
|
|
384
|
+
|
|
385
|
+
def is_empty(self) -> bool:
|
|
386
|
+
"""Keep ownership when missing or inaccessible proof makes the outcome unknown."""
|
|
387
|
+
if self.job is not None:
|
|
388
|
+
return self.job.active_count() == 0
|
|
389
|
+
group_id = _read_group_id(self.control_dir)
|
|
390
|
+
if group_id is not None and _posix_group_exists(group_id):
|
|
391
|
+
return False
|
|
392
|
+
return os.path.isfile(os.path.join(self.control_dir, COMPLETE_BASENAME)) or self.launch_failed()
|
|
393
|
+
|
|
394
|
+
def contains_process(self, process_id: int) -> bool:
|
|
395
|
+
"""Verify that an IPC caller belongs to this operation's native descendant group."""
|
|
396
|
+
if self.job is None:
|
|
397
|
+
group_id = _read_group_id(self.control_dir)
|
|
398
|
+
try:
|
|
399
|
+
return group_id is not None and os.getpgid(process_id) == group_id
|
|
400
|
+
except ProcessLookupError:
|
|
401
|
+
return False
|
|
402
|
+
kernel = self.job.kernel
|
|
403
|
+
handle = kernel.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, process_id)
|
|
404
|
+
if not handle:
|
|
405
|
+
return False
|
|
406
|
+
try:
|
|
407
|
+
contained = wintypes.BOOL()
|
|
408
|
+
return bool(kernel.IsProcessInJob(handle, self.job.handle, ctypes.byref(contained)) and contained.value)
|
|
409
|
+
finally:
|
|
410
|
+
kernel.CloseHandle(handle)
|
|
411
|
+
|
|
412
|
+
def launch_failed(self) -> bool:
|
|
413
|
+
"""Read only the supervisor's launch status, never a credential-bearing diagnostic."""
|
|
414
|
+
return os.path.isfile(os.path.join(self.control_dir, START_FAILED_BASENAME))
|
|
415
|
+
|
|
416
|
+
def result_return_code(self, fallback: int | None) -> int | None:
|
|
417
|
+
"""Restore negative POSIX signal statuses that a supervisor exit cannot encode."""
|
|
418
|
+
try:
|
|
419
|
+
with open(os.path.join(self.control_dir, COMPLETE_BASENAME), encoding="ascii") as stream:
|
|
420
|
+
value = stream.read()
|
|
421
|
+
return int(value) if value else fallback
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
return fallback
|
|
424
|
+
except ValueError as error:
|
|
425
|
+
raise OSError("Invalid supervised process status") from error
|
|
426
|
+
|
|
427
|
+
def close(self) -> None:
|
|
428
|
+
"""Dispose of the already empty job and its private markers."""
|
|
429
|
+
if self.job is not None:
|
|
430
|
+
self.job.close()
|
|
431
|
+
self.control.cleanup()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def caller_job_forbids_breakaway() -> bool:
|
|
435
|
+
"""Tell whether the job holding this process forbids its members to start a process outside it.
|
|
436
|
+
|
|
437
|
+
Windows refuses a launch that asks to leave such a job with the same access-denied error it gives for a program the
|
|
438
|
+
user may not run (file rights, an application control policy, an antivirus), so the job's own limits tell the two
|
|
439
|
+
apart. A process in no job, in a job that lets its members leave, or whose job cannot be read, is taken as not
|
|
440
|
+
forbidden: the launch's own error is then the one shown. Elsewhere than on Windows there is no such job.
|
|
441
|
+
"""
|
|
442
|
+
if os.name != "nt":
|
|
443
|
+
return False
|
|
444
|
+
kernel = _bound_kernel(("GetCurrentProcess", "IsProcessInJob", "QueryInformationJobObject"))
|
|
445
|
+
contained = wintypes.BOOL()
|
|
446
|
+
if not kernel.IsProcessInJob(kernel.GetCurrentProcess(), None, ctypes.byref(contained)) or not contained.value:
|
|
447
|
+
return False
|
|
448
|
+
limit = _JobExtendedLimit()
|
|
449
|
+
# A null job handle reads the job that holds the calling process.
|
|
450
|
+
if not kernel.QueryInformationJobObject(None, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(limit), ctypes.sizeof(limit), None):
|
|
451
|
+
return False
|
|
452
|
+
return not limit.basic_limit.limit_flags & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def process_is_in_a_job(process_id: int) -> bool:
|
|
456
|
+
"""Tell whether any job holds the process, raising when Windows cannot answer.
|
|
457
|
+
|
|
458
|
+
Since Windows 8 jobs nest. A process started with the request to leave its job leaves each job of the chain that
|
|
459
|
+
allows it, one after the other, and silently stays in the first one that forbids it. A helper still in a job right
|
|
460
|
+
after it was started suspended with that request was therefore kept by a job outside GitPane, since GitPane's own
|
|
461
|
+
jobs all let it leave. Elsewhere than on Windows there are no jobs.
|
|
462
|
+
"""
|
|
463
|
+
if os.name != "nt":
|
|
464
|
+
return False
|
|
465
|
+
kernel = _bound_kernel(("OpenProcess", "IsProcessInJob", "CloseHandle"))
|
|
466
|
+
process_handle = kernel.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, process_id)
|
|
467
|
+
if not process_handle:
|
|
468
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
469
|
+
try:
|
|
470
|
+
contained = wintypes.BOOL()
|
|
471
|
+
# A null job handle asks about any job rather than one given job.
|
|
472
|
+
if not kernel.IsProcessInJob(process_handle, None, ctypes.byref(contained)):
|
|
473
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
474
|
+
return bool(contained.value)
|
|
475
|
+
finally:
|
|
476
|
+
kernel.CloseHandle(process_handle)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class JobBreakawayRefusedError(OSError):
|
|
480
|
+
"""A job holding GitPane, the one GitPane runs in or one further out, keeps the process, so a released launch cannot go on.
|
|
481
|
+
|
|
482
|
+
Starting the process inside that job, or inside the supervised group instead, would expose what it opens, such as
|
|
483
|
+
the user's browser, to the stopping of that job or group.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class ReleasedChildJob:
|
|
488
|
+
"""Run one process that is stopped with this job, while every process it starts is left running on its own.
|
|
489
|
+
|
|
490
|
+
Git Credential Manager opens the user's browser as a child process during a browser sign-in. In a supervised group
|
|
491
|
+
that browser would be a member like any helper: the group would only count as empty once the user closes the
|
|
492
|
+
browser, and stopping the group would close it. On Windows the process therefore leaves the supervised group when it
|
|
493
|
+
starts (that group must allow it) and is held in this private job instead. The kernel stops the job's members when
|
|
494
|
+
``stop`` is called or when the job's owner exits, so the process can never outlive the worker that started it;
|
|
495
|
+
the processes it starts leave the job at once, so the browser is never stopped by GitPane. When a job outside GitPane
|
|
496
|
+
forbids leaving it, the process stays in that job without any error; it is then stopped before it has run anything
|
|
497
|
+
and the launch is refused, since that job could close the browser too.
|
|
498
|
+
|
|
499
|
+
Elsewhere, the process runs in a session of its own, and the browser it opens stays in that session and in its group
|
|
500
|
+
(``xdg-open`` and ``open`` move it to neither). Only the process itself is ever stopped, never its group, so the
|
|
501
|
+
browser keeps running. On Linux the kernel also stops the process as soon as the worker that started it exits, as
|
|
502
|
+
the Windows job would, and the supervisor does not wait for what is left of that session once its leader has ended.
|
|
503
|
+
macOS has no such signal, so a small watcher leads that session as the process's parent: the kernel tells it when
|
|
504
|
+
the worker ends, and it then stops the process alone. The watcher hands the process its streams and ends with its
|
|
505
|
+
exit code, so the worker reads the watcher as it would read the process.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
def __init__(self, caller_job_forbids_breakaway: Callable[[], bool] = caller_job_forbids_breakaway,
|
|
509
|
+
process_is_in_a_job: Callable[[int], bool] = process_is_in_a_job, platform: str = sys.platform) -> None:
|
|
510
|
+
"""Pick the system's guard, and create the private job before anything is started where the guard is a job.
|
|
511
|
+
|
|
512
|
+
``caller_job_forbids_breakaway`` reads the limits of the job holding GitPane, and ``process_is_in_a_job`` tells
|
|
513
|
+
whether a job still holds the process just started; the tests replace both, since Windows alone has such jobs.
|
|
514
|
+
``platform`` is the system whose guard is used.
|
|
515
|
+
"""
|
|
516
|
+
self.guard = released_child_guard(platform)
|
|
517
|
+
self.job = _WindowsJob(None, limit_flags=JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) if self.guard is ReleasedChildGuard.JOB else None
|
|
518
|
+
self.process: subprocess.Popen | None = None
|
|
519
|
+
self.caller_job_forbids_breakaway = caller_job_forbids_breakaway
|
|
520
|
+
self.process_is_in_a_job = process_is_in_a_job
|
|
521
|
+
|
|
522
|
+
def start(self, command: list[str], environment_by_name: dict[str, str]) -> subprocess.Popen:
|
|
523
|
+
"""Start the process with piped standard streams, already a member of this job before it runs any code.
|
|
524
|
+
|
|
525
|
+
Raises ``JobBreakawayRefusedError`` without starting anything when the caller's own job forbids leaving it, and
|
|
526
|
+
after stopping the process before it has run anything when a job outside GitPane keeps it. Any other refusal,
|
|
527
|
+
such as a helper program the user may not run, is raised as it came. A process stopped that way is waited for
|
|
528
|
+
and its pipes closed before the refusal is raised, so nothing of it is left behind.
|
|
529
|
+
|
|
530
|
+
On macOS the process returned is the watcher that runs the helper: it shares the helper's streams and ends with
|
|
531
|
+
the helper's exit code, and a helper the watcher cannot start shows up as its exit code 127 with the reason on
|
|
532
|
+
standard error.
|
|
533
|
+
"""
|
|
534
|
+
from gitpane.config import ISOLATED_INTERPRETER_OPTIONS, PROCESS_TERMINATE_TIMEOUT_MS
|
|
535
|
+
|
|
536
|
+
pipe_by_stream = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
|
|
537
|
+
if self.job is None and self.guard is ReleasedChildGuard.WATCHER:
|
|
538
|
+
watched_command = [supervisor_interpreter_filename(), *ISOLATED_INTERPRETER_OPTIONS, released_child_watch_filename(), str(os.getpid()), *command]
|
|
539
|
+
self.process = subprocess.Popen(watched_command, env=environment_by_name, start_new_session=True, **pipe_by_stream)
|
|
540
|
+
return self.process
|
|
541
|
+
if self.job is None:
|
|
542
|
+
# Workaround: the parent-death signal can only be requested by the new process itself, so it goes through
|
|
543
|
+
# preexec_fn, which runs between fork and exec. That is only unsafe while other threads run, and the
|
|
544
|
+
# sign-in worker that starts the helper runs none (on POSIX, communicate() waits without threads).
|
|
545
|
+
stop_with_owner = _stop_with_owner_function(os.getpid())
|
|
546
|
+
self.process = subprocess.Popen(command, env=environment_by_name, start_new_session=True, preexec_fn=stop_with_owner, **pipe_by_stream) # noqa: PLW1509 -- see above
|
|
547
|
+
return self.process
|
|
548
|
+
try:
|
|
549
|
+
process = subprocess.Popen(command, env=environment_by_name, creationflags=CREATE_SUSPENDED | CREATE_BREAKAWAY_FROM_JOB, **pipe_by_stream)
|
|
550
|
+
except PermissionError as error:
|
|
551
|
+
if not self.caller_job_forbids_breakaway():
|
|
552
|
+
raise
|
|
553
|
+
# A job that forbids leaving it holds this worker. Starting the process inside the supervised group instead
|
|
554
|
+
# would put the browser it opens in that group too, and stopping the group would close the user's browser,
|
|
555
|
+
# so the launch is refused rather than retried.
|
|
556
|
+
raise JobBreakawayRefusedError("The calling job does not allow a process to leave it") from error
|
|
557
|
+
self.process = process
|
|
558
|
+
try:
|
|
559
|
+
# The process is still suspended and has run nothing, so it cannot have opened a browser yet. A job outside
|
|
560
|
+
# GitPane that forbids leaving it keeps the process without any error from Windows; only asking which job
|
|
561
|
+
# holds it now shows that, and the process is then stopped before it runs.
|
|
562
|
+
if self.process_is_in_a_job(process.pid):
|
|
563
|
+
raise JobBreakawayRefusedError("A job outside GitPane keeps the process it starts")
|
|
564
|
+
self.job.adopt_suspended(process.pid)
|
|
565
|
+
except OSError:
|
|
566
|
+
process.kill()
|
|
567
|
+
# The refused process must hold neither a running process nor open handles until the garbage collector
|
|
568
|
+
# finds it, nor make Python warn that it is still running. A suspended process that has run nothing ends as
|
|
569
|
+
# soon as Windows stops it; if it still has not ended in time, the refusal is what the user must see.
|
|
570
|
+
try:
|
|
571
|
+
process.wait(timeout=PROCESS_TERMINATE_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
|
|
572
|
+
except subprocess.TimeoutExpired:
|
|
573
|
+
pass
|
|
574
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
575
|
+
if stream is not None:
|
|
576
|
+
stream.close()
|
|
577
|
+
self.process = None
|
|
578
|
+
raise
|
|
579
|
+
return process
|
|
580
|
+
|
|
581
|
+
def stop(self) -> None:
|
|
582
|
+
"""Stop the process now, leaving alone the processes it started.
|
|
583
|
+
|
|
584
|
+
Elsewhere than on Windows only the process is signalled, never its group: the browser it opened is a member of
|
|
585
|
+
that group. The process is still unreaped while it runs, so its ID cannot name another process yet. On macOS the
|
|
586
|
+
watcher is asked to stop, and it stops the helper alone, its own unreaped child, before it ends.
|
|
587
|
+
"""
|
|
588
|
+
if self.job is not None:
|
|
589
|
+
self.job.stop()
|
|
590
|
+
return
|
|
591
|
+
if self.process is None or self.process.poll() is not None:
|
|
592
|
+
return
|
|
593
|
+
if self.guard is ReleasedChildGuard.WATCHER:
|
|
594
|
+
self.process.terminate()
|
|
595
|
+
return
|
|
596
|
+
self.process.kill()
|
|
597
|
+
|
|
598
|
+
def close(self) -> None:
|
|
599
|
+
"""Release the job; a member still running is stopped by the kernel when its last handle closes.
|
|
600
|
+
|
|
601
|
+
Elsewhere there is no job to close, so a process still running is stopped here, the way the job would stop it.
|
|
602
|
+
"""
|
|
603
|
+
if self.job is not None:
|
|
604
|
+
self.job.close()
|
|
605
|
+
return
|
|
606
|
+
self.stop()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _stop_with_owner_function(owner_id: int) -> Callable[[], None]:
|
|
610
|
+
"""Return what a new Linux process runs before its program: ask the kernel to stop it when ``owner_id``, its parent, exits.
|
|
611
|
+
|
|
612
|
+
This is the Linux counterpart of the Windows job that stops its member when its owner exits. The C library is
|
|
613
|
+
bound here, in the parent, so the new process only makes the call. A process whose parent already exited before
|
|
614
|
+
the request took effect, or whose request was refused, ends before its program runs rather than run unguarded.
|
|
615
|
+
"""
|
|
616
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
617
|
+
import signal
|
|
618
|
+
|
|
619
|
+
def ask_kernel() -> None:
|
|
620
|
+
"""Run in the new process, between its creation and its program."""
|
|
621
|
+
if library.prctl(LINUX_SET_PARENT_DEATH_SIGNAL, signal.SIGKILL, 0, 0, 0) != 0:
|
|
622
|
+
os._exit(PROCESS_START_FAILED_EXIT_CODE)
|
|
623
|
+
if os.getppid() != owner_id:
|
|
624
|
+
os._exit(PROCESS_STOP_EXIT_CODE)
|
|
625
|
+
|
|
626
|
+
return ask_kernel
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _write_marker(control_dir: str, basename: str, value: str = "") -> None:
|
|
630
|
+
"""Atomically publish small control records so polling never reads a partial PID."""
|
|
631
|
+
temporary_filename = os.path.join(control_dir, basename + ".tmp-" + str(os.getpid()))
|
|
632
|
+
with open(temporary_filename, "w", encoding="ascii") as stream:
|
|
633
|
+
stream.write(value)
|
|
634
|
+
os.replace(temporary_filename, os.path.join(control_dir, basename))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _read_group_id(control_dir: str) -> int | None:
|
|
638
|
+
"""Read the isolated session identity once the supervisor has started the target."""
|
|
639
|
+
try:
|
|
640
|
+
with open(os.path.join(control_dir, GROUP_BASENAME), encoding="ascii") as stream:
|
|
641
|
+
group_id = int(stream.read())
|
|
642
|
+
except FileNotFoundError:
|
|
643
|
+
return None
|
|
644
|
+
except ValueError as error:
|
|
645
|
+
raise OSError("Invalid supervised process group") from error
|
|
646
|
+
if group_id < MINIMUM_TRACKED_PROCESS_ID:
|
|
647
|
+
raise OSError("Invalid supervised process group")
|
|
648
|
+
return group_id
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _posix_group_exists(group_id: int) -> bool:
|
|
652
|
+
"""Tell whether any member of the group, a zombie included, still exists, so termination is never invented.
|
|
653
|
+
|
|
654
|
+
A group whose remaining members are zombies not yet reaped still exists: it disappears once they are reaped, and
|
|
655
|
+
the caller checks again. Any other refusal of the system is raised, since the outcome is then unknown.
|
|
656
|
+
"""
|
|
657
|
+
try:
|
|
658
|
+
os.killpg(group_id, 0)
|
|
659
|
+
except ProcessLookupError:
|
|
660
|
+
return False
|
|
661
|
+
except PermissionError:
|
|
662
|
+
# Workaround: macOS answers EPERM for a group whose remaining members are all zombies not yet reaped, where
|
|
663
|
+
# Linux accepts the request; the group still exists until they are reaped.
|
|
664
|
+
return True
|
|
665
|
+
return True
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _stop_posix_group(group_id: int) -> None:
|
|
669
|
+
"""Stop the entire isolated group while leaving the supervisor alive to reap it.
|
|
670
|
+
|
|
671
|
+
A group that no longer exists, or whose remaining members are zombies not yet reaped, has nothing left to stop.
|
|
672
|
+
Any other refusal of the system is raised.
|
|
673
|
+
"""
|
|
674
|
+
import signal
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
os.killpg(group_id, signal.SIGKILL)
|
|
678
|
+
except ProcessLookupError:
|
|
679
|
+
pass
|
|
680
|
+
except PermissionError:
|
|
681
|
+
# Workaround: macOS answers EPERM to killpg on a group whose remaining members are all zombies not yet reaped,
|
|
682
|
+
# where Linux succeeds; nothing alive is left to stop, and reaping them is what ends the group.
|
|
683
|
+
pass
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _reap_exited_children() -> bool:
|
|
687
|
+
"""Reap every child that has exited, adopted ones included, and report whether a child still runs."""
|
|
688
|
+
while True:
|
|
689
|
+
try:
|
|
690
|
+
process_id, _ = os.waitpid(-1, os.WNOHANG)
|
|
691
|
+
except ChildProcessError:
|
|
692
|
+
return False
|
|
693
|
+
if process_id == 0:
|
|
694
|
+
return True
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _running_child_records() -> list[tuple[int, int, int]] | None:
|
|
698
|
+
"""List the supervisor's running children, adopted ones included, as (process ID, group ID, session ID); None when they cannot be read.
|
|
699
|
+
|
|
700
|
+
No system call lists the children of a process: Linux only gives each process's parent, group and session in its
|
|
701
|
+
own /proc entry, so every entry is read. This only runs while a child outlives the target, which is rare.
|
|
702
|
+
"""
|
|
703
|
+
supervisor_id = os.getpid()
|
|
704
|
+
child_records = []
|
|
705
|
+
try:
|
|
706
|
+
proc_entries = os.scandir(PROC_DIR)
|
|
707
|
+
except OSError:
|
|
708
|
+
return None
|
|
709
|
+
with proc_entries:
|
|
710
|
+
for proc_entry in proc_entries:
|
|
711
|
+
if not proc_entry.name.isdigit():
|
|
712
|
+
continue
|
|
713
|
+
try:
|
|
714
|
+
with open(os.path.join(proc_entry.path, PROC_STAT_BASENAME), encoding="ascii", errors="replace") as stream:
|
|
715
|
+
stat_text = stream.read()
|
|
716
|
+
except OSError:
|
|
717
|
+
# The process ended since the folder was listed; one of the supervisor's own is always readable.
|
|
718
|
+
continue
|
|
719
|
+
# The command name comes first, between parentheses, and may itself contain spaces or parentheses.
|
|
720
|
+
stat_fields = stat_text.rpartition(")")[2].split()
|
|
721
|
+
if stat_fields[PROC_STAT_STATE_INDEX] == ZOMBIE_STATE or int(stat_fields[PROC_STAT_PARENT_INDEX]) != supervisor_id:
|
|
722
|
+
continue
|
|
723
|
+
child_records.append((int(proc_entry.name), int(stat_fields[PROC_STAT_GROUP_INDEX]), int(stat_fields[PROC_STAT_SESSION_INDEX])))
|
|
724
|
+
return child_records
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _session_leader_runs(session_id: int) -> bool:
|
|
728
|
+
"""Tell whether the process that leads a session still exists.
|
|
729
|
+
|
|
730
|
+
While any member of the session remains, Linux keeps its ID reserved, so the ID cannot name another process.
|
|
731
|
+
"""
|
|
732
|
+
try:
|
|
733
|
+
os.kill(session_id, 0)
|
|
734
|
+
except ProcessLookupError:
|
|
735
|
+
return False
|
|
736
|
+
except PermissionError:
|
|
737
|
+
return True
|
|
738
|
+
return True
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _reap_orphan(target_id: int, *, stopping: bool) -> bool:
|
|
742
|
+
"""Reap adopted Linux descendants and report whether one must still be waited for; when ``stopping``, stop what the target's session still runs.
|
|
743
|
+
|
|
744
|
+
``target_id`` is the target's process ID, which is also the ID of the group and of the session it leads. A child in
|
|
745
|
+
the target's session is waited for: a member of its group, or a program the target started in a group of its own
|
|
746
|
+
so that it can stop that program with everything it started. When the command is being stopped, each such group is
|
|
747
|
+
stopped as a whole, as the Windows job stops all of its members. A child that leads a session of its own, as Git's
|
|
748
|
+
background maintenance does once detached, is waited for too: it may still change repository data. Only a child
|
|
749
|
+
left behind by another session whose leader has ended is not waited for: that is what a released credential
|
|
750
|
+
helper opened before it ended, the user's browser, which is never stopped either. When the children cannot be
|
|
751
|
+
read, every one is waited for.
|
|
752
|
+
|
|
753
|
+
Once the command is being stopped, nothing outside the target's session is waited for any more. Such a child is
|
|
754
|
+
never stopped (it may be the user's browser, an SSH ControlPersist master or a gpg-agent that daemonized), so
|
|
755
|
+
waiting for it after a stop would keep the supervisor alive until it is killed, even though Git itself has ended.
|
|
756
|
+
"""
|
|
757
|
+
if not _reap_exited_children():
|
|
758
|
+
return False
|
|
759
|
+
child_records = _running_child_records()
|
|
760
|
+
if child_records is None:
|
|
761
|
+
return True
|
|
762
|
+
waiting = False
|
|
763
|
+
for process_id, group_id, session_id in child_records:
|
|
764
|
+
if session_id == target_id:
|
|
765
|
+
if stopping and group_id != target_id:
|
|
766
|
+
_stop_posix_group(group_id)
|
|
767
|
+
waiting = True
|
|
768
|
+
continue
|
|
769
|
+
if stopping:
|
|
770
|
+
continue
|
|
771
|
+
if process_id == session_id or _session_leader_runs(session_id):
|
|
772
|
+
waiting = True
|
|
773
|
+
return waiting
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def supervise(control_dir: str, job_name: str, program_filename: str, arguments: list[str], *, poll_ms: int, owner_id: int) -> int:
|
|
777
|
+
"""Run a target with unchanged streams and stay until all of its helpers exit, checking again every ``poll_ms``.
|
|
778
|
+
|
|
779
|
+
On Windows the supervisor blocks on the target itself, and only then checks the job until nobody but the supervisor
|
|
780
|
+
is left: stopping goes through the job from the application, so nothing needs watching while the target runs.
|
|
781
|
+
Elsewhere the stop marker must be watched while the target runs, so the target is polled. Once it has exited, the
|
|
782
|
+
supervisor stays while a member of the target's group runs, and on Linux while a descendant it adopted may still
|
|
783
|
+
change repository data; what a released credential helper opened, such as the user's browser, is not waited for.
|
|
784
|
+
|
|
785
|
+
On macOS and Linux the supervisor also watches the process that started it, GitPane, whose process ID is
|
|
786
|
+
``owner_id``: when its parent is no longer GitPane, GitPane has died, and the target's group is stopped exactly as
|
|
787
|
+
for a stop request. A supervisor whose parent is already not GitPane when it starts runs nothing and records its end
|
|
788
|
+
as for a stop requested before the launch. The parent is compared with GitPane itself, not with the first process
|
|
789
|
+
of the system, because a Linux desktop session adopts orphans in a process of its own, and GitPane may itself be
|
|
790
|
+
that first process in a container.
|
|
791
|
+
"""
|
|
792
|
+
job = None
|
|
793
|
+
child = None
|
|
794
|
+
watching_owner = watches_owner_exit(sys.platform)
|
|
795
|
+
try:
|
|
796
|
+
if os.name == "nt":
|
|
797
|
+
job = _WindowsJob(job_name, existing=True)
|
|
798
|
+
job.join_current_process()
|
|
799
|
+
elif sys.platform.startswith(LINUX_PLATFORM):
|
|
800
|
+
# Adopt orphan grandchildren so their zombies cannot keep an empty group alive, and so what the target
|
|
801
|
+
# leaves behind can still be waited for or stopped.
|
|
802
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
803
|
+
if library.prctl(LINUX_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
|
|
804
|
+
raise OSError(ctypes.get_errno(), "Could not track orphan descendants")
|
|
805
|
+
if os.path.isfile(os.path.join(control_dir, STOP_BASENAME)) or (watching_owner and os.getppid() != owner_id):
|
|
806
|
+
_write_marker(control_dir, COMPLETE_BASENAME)
|
|
807
|
+
return PROCESS_STOP_EXIT_CODE
|
|
808
|
+
child = subprocess.Popen([program_filename, *arguments], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, start_new_session=os.name != "nt",
|
|
809
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
810
|
+
_write_marker(control_dir, GROUP_BASENAME, str(child.pid))
|
|
811
|
+
if job is not None:
|
|
812
|
+
return_code = child.wait()
|
|
813
|
+
while job.active_count() != SUPERVISOR_PROCESS_COUNT:
|
|
814
|
+
time.sleep(poll_ms / MILLISECONDS_PER_SECOND)
|
|
815
|
+
_write_marker(control_dir, COMPLETE_BASENAME, str(return_code))
|
|
816
|
+
return return_code
|
|
817
|
+
while True:
|
|
818
|
+
stopping = os.path.isfile(os.path.join(control_dir, STOP_BASENAME)) or (watching_owner and os.getppid() != owner_id)
|
|
819
|
+
if stopping:
|
|
820
|
+
_stop_posix_group(child.pid)
|
|
821
|
+
return_code = child.poll()
|
|
822
|
+
if return_code is not None:
|
|
823
|
+
orphan_alive = _reap_orphan(child.pid, stopping=stopping)
|
|
824
|
+
drained = not _posix_group_exists(child.pid) and not orphan_alive
|
|
825
|
+
if drained:
|
|
826
|
+
_write_marker(control_dir, COMPLETE_BASENAME, str(return_code))
|
|
827
|
+
return return_code
|
|
828
|
+
time.sleep(poll_ms / MILLISECONDS_PER_SECOND)
|
|
829
|
+
except OSError as error:
|
|
830
|
+
if child is None:
|
|
831
|
+
_write_marker(control_dir, START_FAILED_BASENAME)
|
|
832
|
+
# Keep errors on the existing stderr path, where credential diagnostics are filtered.
|
|
833
|
+
print(str(error), file=sys.stderr, flush=True)
|
|
834
|
+
return PROCESS_START_FAILED_EXIT_CODE
|
|
835
|
+
finally:
|
|
836
|
+
if job is not None:
|
|
837
|
+
job.close()
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
if __name__ == "__main__":
|
|
841
|
+
supervisor_arguments = sys.argv[SUPERVISOR_ARGUMENT_OFFSET:]
|
|
842
|
+
control_dir, job_name, poll_ms_value, owner_id_value = supervisor_arguments[:SUPERVISOR_SETTING_ARGUMENT_COUNT]
|
|
843
|
+
program_filename, *arguments = supervisor_arguments[SUPERVISOR_SETTING_ARGUMENT_COUNT:]
|
|
844
|
+
try:
|
|
845
|
+
poll_ms = supervisor_poll_ms(poll_ms_value)
|
|
846
|
+
supervisor_owner = supervisor_owner_id(owner_id_value)
|
|
847
|
+
except ValueError as error:
|
|
848
|
+
_write_marker(control_dir, START_FAILED_BASENAME)
|
|
849
|
+
print(str(error), file=sys.stderr, flush=True)
|
|
850
|
+
raise SystemExit(PROCESS_START_FAILED_EXIT_CODE) from None
|
|
851
|
+
raise SystemExit(supervise(control_dir, job_name, program_filename, arguments, poll_ms=poll_ms, owner_id=supervisor_owner))
|