multiSSH3 6.33__tar.gz → 6.34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {multissh3-6.33/multiSSH3.egg-info → multissh3-6.34}/PKG-INFO +4 -2
  2. {multissh3-6.33 → multissh3-6.34}/README.md +3 -1
  3. {multissh3-6.33 → multissh3-6.34}/mssh_complete.py +3 -6
  4. {multissh3-6.33 → multissh3-6.34/multiSSH3.egg-info}/PKG-INFO +4 -2
  5. {multissh3-6.33 → multissh3-6.34}/multiSSH3.py +91 -17
  6. {multissh3-6.33 → multissh3-6.34}/tests/complete_shell_harness.py +18 -2
  7. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_diagnose.py +4 -3
  8. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_keys.py +2 -2
  9. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_shells.py +2 -2
  10. {multissh3-6.33 → multissh3-6.34}/tests/test_curses_event_loop.py +28 -0
  11. {multissh3-6.33 → multissh3-6.34}/tests/test_expand_hostnames.py +15 -0
  12. multissh3-6.34/tests/test_stopped_commands.py +287 -0
  13. multissh3-6.33/tests/test_stopped_commands.py +0 -123
  14. {multissh3-6.33 → multissh3-6.34}/LICENSE +0 -0
  15. {multissh3-6.33 → multissh3-6.34}/MANIFEST.in +0 -0
  16. {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/SOURCES.txt +0 -0
  17. {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/dependency_links.txt +0 -0
  18. {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/entry_points.txt +0 -0
  19. {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/top_level.txt +0 -0
  20. {multissh3-6.33 → multissh3-6.34}/pytest.ini +0 -0
  21. {multissh3-6.33 → multissh3-6.34}/setup.cfg +0 -0
  22. {multissh3-6.33 → multissh3-6.34}/setup.py +0 -0
  23. {multissh3-6.33 → multissh3-6.34}/tests/bench_complete.py +0 -0
  24. {multissh3-6.33 → multissh3-6.34}/tests/conftest.py +0 -0
  25. {multissh3-6.33 → multissh3-6.34}/tests/data/run_command_golden.json +0 -0
  26. {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.bash5.1 +0 -0
  27. {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.bash5.3 +0 -0
  28. {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.centos7 +0 -0
  29. {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.current +0 -0
  30. {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.legacy +0 -0
  31. {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.modern +0 -0
  32. {multissh3-6.33 → multissh3-6.34}/tests/shells/run_matrix.sh +0 -0
  33. {multissh3-6.33 → multissh3-6.34}/tests/test_cli_branches.py +0 -0
  34. {multissh3-6.33 → multissh3-6.34}/tests/test_cli_option_words.py +0 -0
  35. {multissh3-6.33 → multissh3-6.34}/tests/test_cli_processing.py +0 -0
  36. {multissh3-6.33 → multissh3-6.34}/tests/test_closed_output.py +0 -0
  37. {multissh3-6.33 → multissh3-6.34}/tests/test_color_helpers.py +0 -0
  38. {multissh3-6.33 → multissh3-6.34}/tests/test_command_terminal.py +0 -0
  39. {multissh3-6.33 → multissh3-6.34}/tests/test_compact_hostnames.py +0 -0
  40. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_blesh.py +0 -0
  41. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_cli.py +0 -0
  42. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_context.py +0 -0
  43. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_editor.py +0 -0
  44. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_engine.py +0 -0
  45. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_eval.py +0 -0
  46. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_hosts.py +0 -0
  47. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_hoststate.py +0 -0
  48. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_index.py +0 -0
  49. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_lex.py +0 -0
  50. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_mssh_helpers.py +0 -0
  51. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_password_spellings.py +0 -0
  52. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_picker.py +0 -0
  53. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_probe.py +0 -0
  54. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_start.py +0 -0
  55. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_store.py +0 -0
  56. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_suggest.py +0 -0
  57. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_terminal.py +0 -0
  58. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_untrusted.py +0 -0
  59. {multissh3-6.33 → multissh3-6.34}/tests/test_complete_zsh_options.py +0 -0
  60. {multissh3-6.33 → multissh3-6.34}/tests/test_config_cli.py +0 -0
  61. {multissh3-6.33 → multissh3-6.34}/tests/test_config_validate.py +0 -0
  62. {multissh3-6.33 → multissh3-6.34}/tests/test_config_values.py +0 -0
  63. {multissh3-6.33 → multissh3-6.34}/tests/test_control_master_config.py +0 -0
  64. {multissh3-6.33 → multissh3-6.34}/tests/test_copy_id.py +0 -0
  65. {multissh3-6.33 → multissh3-6.34}/tests/test_curses_branches.py +0 -0
  66. {multissh3-6.33 → multissh3-6.34}/tests/test_curses_tui.py +0 -0
  67. {multissh3-6.33 → multissh3-6.34}/tests/test_curses_wide_chars.py +0 -0
  68. {multissh3-6.33 → multissh3-6.34}/tests/test_file_trust.py +0 -0
  69. {multissh3-6.33 → multissh3-6.34}/tests/test_harness_smoke.py +0 -0
  70. {multissh3-6.33 → multissh3-6.34}/tests/test_helpers_extra.py +0 -0
  71. {multissh3-6.33 → multissh3-6.34}/tests/test_history_replay.py +0 -0
  72. {multissh3-6.33 → multissh3-6.34}/tests/test_host.py +0 -0
  73. {multissh3-6.33 → multissh3-6.34}/tests/test_host_validity.py +0 -0
  74. {multissh3-6.33 → multissh3-6.34}/tests/test_hosts_file.py +0 -0
  75. {multissh3-6.33 → multissh3-6.34}/tests/test_identities_only.py +0 -0
  76. {multissh3-6.33 → multissh3-6.34}/tests/test_ipmi_lockout_wait.py +0 -0
  77. {multissh3-6.33 → multissh3-6.34}/tests/test_ipmi_logins.py +0 -0
  78. {multissh3-6.33 → multissh3-6.34}/tests/test_key_path_home.py +0 -0
  79. {multissh3-6.33 → multissh3-6.34}/tests/test_login_drop_retry.py +0 -0
  80. {multissh3-6.33 → multissh3-6.34}/tests/test_main_lifecycle.py +0 -0
  81. {multissh3-6.33 → multissh3-6.34}/tests/test_narrow_terminal.py +0 -0
  82. {multissh3-6.33 → multissh3-6.34}/tests/test_no_hosts.py +0 -0
  83. {multissh3-6.33 → multissh3-6.34}/tests/test_option_no_effect.py +0 -0
  84. {multissh3-6.33 → multissh3-6.34}/tests/test_options_after_command.py +0 -0
  85. {multissh3-6.33 → multissh3-6.34}/tests/test_output_merge.py +0 -0
  86. {multissh3-6.33 → multissh3-6.34}/tests/test_output_robustness.py +0 -0
  87. {multissh3-6.33 → multissh3-6.34}/tests/test_packaging.py +0 -0
  88. {multissh3-6.33 → multissh3-6.34}/tests/test_password_handling.py +0 -0
  89. {multissh3-6.33 → multissh3-6.34}/tests/test_passwords_out_of_ps.py +0 -0
  90. {multissh3-6.33 → multissh3-6.34}/tests/test_run_command.py +0 -0
  91. {multissh3-6.33 → multissh3-6.34}/tests/test_run_command_branches.py +0 -0
  92. {multissh3-6.33 → multissh3-6.34}/tests/test_run_command_characterization.py +0 -0
  93. {multissh3-6.33 → multissh3-6.34}/tests/test_run_command_exit.py +0 -0
  94. {multissh3-6.33 → multissh3-6.34}/tests/test_run_on_hosts.py +0 -0
  95. {multissh3-6.33 → multissh3-6.34}/tests/test_run_on_hosts_modes.py +0 -0
  96. {multissh3-6.33 → multissh3-6.34}/tests/test_runtime_branch_matrix.py +0 -0
  97. {multissh3-6.33 → multissh3-6.34}/tests/test_runtime_dir.py +0 -0
  98. {multissh3-6.33 → multissh3-6.34}/tests/test_smoke_optional.py +0 -0
  99. {multissh3-6.33 → multissh3-6.34}/tests/test_ssh_error_text.py +0 -0
  100. {multissh3-6.33 → multissh3-6.34}/tests/test_ssh_timeouts.py +0 -0
  101. {multissh3-6.33 → multissh3-6.34}/tests/test_sshpass_exit.py +0 -0
  102. {multissh3-6.33 → multissh3-6.34}/tests/test_start_run_on_hosts.py +0 -0
  103. {multissh3-6.33 → multissh3-6.34}/tests/test_stream_reader.py +0 -0
  104. {multissh3-6.33 → multissh3-6.34}/tests/test_tui_ssh_notices.py +0 -0
  105. {multissh3-6.33 → multissh3-6.34}/tests/test_unavailable_hosts.py +0 -0
  106. {multissh3-6.33 → multissh3-6.34}/tests/test_underscore_settings.py +0 -0
  107. {multissh3-6.33 → multissh3-6.34}/tests/test_wide_wrap.py +0 -0
  108. {multissh3-6.33 → multissh3-6.34}/tests/vt_grid.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 6.33
3
+ Version: 6.34
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -336,6 +336,8 @@ When hostname need to be resolved, mssh will check in the following order:
336
336
 
337
337
  An alias's value is split into hosts as the hosts argument is (`,`, spaces, `;`, ...). An alias with an empty value (`eu=''`) stands for no hosts.
338
338
 
339
+ A member can name its user: with `dbs="postgres@db1,db2"`, mssh logs in to db1 as postgres, and `-u` changes only db2's user. A user typed with the alias replaces it: `mssh root@dbs` logs in to both as root, as a user on the command line wins over ssh config's `User`.
340
+
339
341
  Inside its own expansion, directly or through other aliases, an alias's name is not expanded again but taken as a host name, as bash does with aliases: `web="web,web-b"` runs on the host `web` and on `web-b`. When no host has that name, mssh reports the loop and runs on the rest: `Error: alias loop through 'web' (web -> app -> web): 'web' is not a valid hostname or IP address!`
340
342
 
341
343
 
@@ -970,7 +972,7 @@ Option names count in full only: `--user` is not `--username` and `-c` is not `-
970
972
 
971
973
  Putting the command after `--` is no substitute for quotes: every word after the hosts is a command of its own, so `mssh web1 -- bash -c 'echo hi'` runs `bash`, `-c` and `echo hi` one after another (when each word is a single word, as in `mssh web1 -- ls -la`, mssh asks whether to join them).
972
974
 
973
- Below is a sample help message output from multiSSH3 6.33 @ 2026-10-14 (no config file, 120 columns; the lines after the options list the programs found on that machine)
975
+ Below is a sample help message output from multiSSH3 6.34 @ 2026-10-16 (no config file, 120 columns; the lines after the options list the programs found on that machine)
974
976
 
975
977
  ```bash
976
978
  $ mssh -h
@@ -310,6 +310,8 @@ When hostname need to be resolved, mssh will check in the following order:
310
310
 
311
311
  An alias's value is split into hosts as the hosts argument is (`,`, spaces, `;`, ...). An alias with an empty value (`eu=''`) stands for no hosts.
312
312
 
313
+ A member can name its user: with `dbs="postgres@db1,db2"`, mssh logs in to db1 as postgres, and `-u` changes only db2's user. A user typed with the alias replaces it: `mssh root@dbs` logs in to both as root, as a user on the command line wins over ssh config's `User`.
314
+
313
315
  Inside its own expansion, directly or through other aliases, an alias's name is not expanded again but taken as a host name, as bash does with aliases: `web="web,web-b"` runs on the host `web` and on `web-b`. When no host has that name, mssh reports the loop and runs on the rest: `Error: alias loop through 'web' (web -> app -> web): 'web' is not a valid hostname or IP address!`
314
316
 
315
317
 
@@ -944,7 +946,7 @@ Option names count in full only: `--user` is not `--username` and `-c` is not `-
944
946
 
945
947
  Putting the command after `--` is no substitute for quotes: every word after the hosts is a command of its own, so `mssh web1 -- bash -c 'echo hi'` runs `bash`, `-c` and `echo hi` one after another (when each word is a single word, as in `mssh web1 -- ls -la`, mssh asks whether to join them).
946
948
 
947
- Below is a sample help message output from multiSSH3 6.33 @ 2026-10-14 (no config file, 120 columns; the lines after the options list the programs found on that machine)
949
+ Below is a sample help message output from multiSSH3 6.34 @ 2026-10-16 (no config file, 120 columns; the lines after the options list the programs found on that machine)
948
950
 
949
951
  ```bash
950
952
  $ mssh -h
@@ -16,7 +16,7 @@ import sys
16
16
  import time
17
17
  from bisect import bisect_left
18
18
 
19
- version = '0.6'
19
+ version = '0.7'
20
20
  __version__ = version
21
21
  PROTOCOL = 'MSSHC1'
22
22
  MSSH_NAMES = ('mssh', 'mssh3', 'multissh', 'multissh3', 'multiSSH3')
@@ -2577,12 +2577,9 @@ def _run_name(host, typed_user, line_user, own_user=''):
2577
2577
  The name mssh gives a host in this run: what it records a failure under in unavailable_hosts.csv
2578
2578
  and looks up to skip the host. The user typed with the host, else -u (line_user), else none.
2579
2579
  own_user: the user an alias member is written with. -u does not replace it, a user typed with the
2580
- alias goes in front of it (for root@dbs with dbs=postgres@db1 mssh runs root@postgres@db1).
2580
+ alias does (for root@dbs with dbs=postgres@db1 mssh runs root@db1).
2581
2581
  '''
2582
- if own_user:
2583
- host = own_user + '@' + host
2584
- line_user = ''
2585
- user = typed_user or line_user
2582
+ user = typed_user or own_user or line_user
2586
2583
  return user + '@' + host if user else host
2587
2584
 
2588
2585
  # characters that can start an expansion the shell makes before mssh sees the word
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 6.33
3
+ Version: 6.34
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -336,6 +336,8 @@ When hostname need to be resolved, mssh will check in the following order:
336
336
 
337
337
  An alias's value is split into hosts as the hosts argument is (`,`, spaces, `;`, ...). An alias with an empty value (`eu=''`) stands for no hosts.
338
338
 
339
+ A member can name its user: with `dbs="postgres@db1,db2"`, mssh logs in to db1 as postgres, and `-u` changes only db2's user. A user typed with the alias replaces it: `mssh root@dbs` logs in to both as root, as a user on the command line wins over ssh config's `User`.
340
+
339
341
  Inside its own expansion, directly or through other aliases, an alias's name is not expanded again but taken as a host name, as bash does with aliases: `web="web,web-b"` runs on the host `web` and on `web-b`. When no host has that name, mssh reports the loop and runs on the rest: `Error: alias loop through 'web' (web -> app -> web): 'web' is not a valid hostname or IP address!`
340
342
 
341
343
 
@@ -970,7 +972,7 @@ Option names count in full only: `--user` is not `--username` and `-c` is not `-
970
972
 
971
973
  Putting the command after `--` is no substitute for quotes: every word after the hosts is a command of its own, so `mssh web1 -- bash -c 'echo hi'` runs `bash`, `-c` and `echo hi` one after another (when each word is a single word, as in `mssh web1 -- ls -la`, mssh asks whether to join them).
972
974
 
973
- Below is a sample help message output from multiSSH3 6.33 @ 2026-10-14 (no config file, 120 columns; the lines after the options list the programs found on that machine)
975
+ Below is a sample help message output from multiSSH3 6.34 @ 2026-10-16 (no config file, 120 columns; the lines after the options list the programs found on that machine)
974
976
 
975
977
  ```bash
976
978
  $ mssh -h
@@ -40,10 +40,10 @@ from itertools import count, product
40
40
  from pprint import pformat
41
41
 
42
42
 
43
- version = '6.33'
43
+ version = '6.34'
44
44
  VERSION = version
45
45
  __version__ = version
46
- COMMIT_DATE = '2026-10-14'
46
+ COMMIT_DATE = '2026-10-16'
47
47
  # TODO: Add terminal TUI
48
48
 
49
49
  # no current-directory entry: a cloned repo or unpacked archive could set any global (e.g. _sshPath),
@@ -2642,9 +2642,10 @@ def __expand_hostnames(hosts, hosts_only=False) -> dict:
2642
2642
  if username:
2643
2643
  # we expand the username
2644
2644
  username = sorted(__expand_hostname(username,validate=False))
2645
- # we combine the username and hostname
2645
+ # we combine the username and hostname: the typed user replaces the one an alias member
2646
+ # is written with, as the command line wins over ssh config's User
2646
2647
  for user in username:
2647
- [expandedhosts.update({f'{user}@{host}':ip}) for host,ip in zip(hostSetToAdd,iplist)]
2648
+ [expandedhosts.update({f'{user}@{host.rpartition("@")[2]}':ip}) for host,ip in zip(hostSetToAdd,iplist)]
2648
2649
  else:
2649
2650
  [expandedhosts.update({host:ip}) for host,ip in zip(hostSetToAdd,iplist)]
2650
2651
  return expandedhosts
@@ -2697,6 +2698,8 @@ def expand_hostnames_local(hosts):
2697
2698
  else:
2698
2699
  members = [(user, name, source, None)]
2699
2700
  for member_user, member, member_source, _ in members:
2701
+ # a typed user replaces a member's own, as in expand_hostnames
2702
+ member_user = user or member_user
2700
2703
  full = '{}@{}'.format(member_user, member) if member_user else member
2701
2704
  expanded.add(full)
2702
2705
  if member_source is None:
@@ -3325,15 +3328,19 @@ def _stopped_command_note(formatedCMD):
3325
3328
  Only the local program ends: a command ssh runs on a host gets no signal (it has no terminal), it
3326
3329
  runs on until it ends, or until it writes to the closed connection.
3327
3330
  '''
3328
- program = formatedCMD[0] if formatedCMD else ''
3329
- if program == _binPaths.get('sshpass') and len(formatedCMD) > 2:
3330
- # sshpass -e program, or sshpass -p password program
3331
- program = formatedCMD[2] if formatedCMD[1] == '-e' else formatedCMD[3] if len(formatedCMD) > 3 else program
3332
- if program == _binPaths.get('ssh'):
3331
+ # the program under sshpass, also in --copy_id's sh -c line
3332
+ name = _sshpass_program(formatedCMD)
3333
+ if not name:
3334
+ program = str(formatedCMD[0]) if formatedCMD else ''
3335
+ # known by the path mssh found it at (ssh.EXE on Windows)
3336
+ name = next((known for known in ('ssh', 'rsync', 'scp') if program == _binPaths.get(known)), os.path.basename(program))
3337
+ if name == 'ssh':
3333
3338
  return 'ssh was stopped: the command may still run on the host'
3334
- name = os.path.basename(str(program))
3335
- if program in (_binPaths.get('rsync'), _binPaths.get('scp')):
3339
+ if name in ('rsync', 'scp'):
3336
3340
  return f'{name} was stopped: the copy may be incomplete'
3341
+ if name == 'ssh-copy-id':
3342
+ # after its clean-up it may say all keys were skipped as already there
3343
+ return 'ssh-copy-id was stopped: the key may not be installed'
3337
3344
  return f'{name} was stopped'
3338
3345
 
3339
3346
  def _ipmi_failure_should_wait(host, line_starts=(0, 0, 0)):
@@ -4225,9 +4232,7 @@ def _execute_command(host, formatedCMD, sem, timeout, retry_limit, passwords=(),
4225
4232
  stopped = f'No output for {timeout} s, so {_stopped_command_note(formatedCMD)}.'
4226
4233
  host.stderr.append(stopped)
4227
4234
  host.output.append(stopped)
4228
- proc.send_signal(signal.SIGINT)
4229
- time.sleep(0.1)
4230
- proc.terminate()
4235
+ _stop_command(proc, formatedCMD)
4231
4236
  break
4232
4237
  elif time.monotonic() - host.lastUpdateTime > max(1, timeout // 2):
4233
4238
  timeoutLine = f'Timeout in [{timeout - int(time.monotonic() - host.lastUpdateTime)}] seconds!'
@@ -4247,9 +4252,7 @@ def _execute_command(host, formatedCMD, sem, timeout, retry_limit, passwords=(),
4247
4252
  host.stderr.append('Ctrl C detected, Emergency Stop!')
4248
4253
  host.output.append('Ctrl C detected, Emergency Stop!')
4249
4254
  stopped_by_ctrl_c = True
4250
- proc.send_signal(signal.SIGINT)
4251
- time.sleep(0.1)
4252
- proc.terminate()
4255
+ _stop_command(proc, formatedCMD)
4253
4256
  break
4254
4257
  time.sleep(sleep_interval) # avoid busy-waiting
4255
4258
  if sleep_interval < 0.001:
@@ -4378,6 +4381,71 @@ def _execute_command(host, formatedCMD, sem, timeout, retry_limit, passwords=(),
4378
4381
  retry_limit -= 1
4379
4382
  return False, retry_limit, attempt_line_starts
4380
4383
 
4384
+ def _child_pids(pid):
4385
+ '''The pids of pid's child processes; [] when neither /proc nor pgrep can list them.'''
4386
+ try:
4387
+ with open('/proc/{0}/task/{0}/children'.format(pid)) as children:
4388
+ return [int(child) for child in children.read().split()]
4389
+ except (OSError, ValueError):
4390
+ pass
4391
+ try:
4392
+ listed = subprocess.run(['pgrep', '-P', str(pid)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1).stdout
4393
+ return [int(child) for child in listed.split()]
4394
+ except (OSError, ValueError, subprocess.SubprocessError):
4395
+ return []
4396
+
4397
+ def _sessions_below(pid):
4398
+ '''
4399
+ The processes below pid that lead a session of their own (sshpass makes its command one); each is
4400
+ its process group. Never init's or mssh's own group: pgrep -P 0 lists init.
4401
+ '''
4402
+ if not isinstance(pid, int) or pid <= 1:
4403
+ return []
4404
+ leaders = []
4405
+ seen = {pid}
4406
+ pids = _child_pids(pid)
4407
+ while pids:
4408
+ child = pids.pop()
4409
+ if child in seen or child <= 1:
4410
+ continue
4411
+ seen.add(child)
4412
+ try:
4413
+ if os.getsid(child) == child:
4414
+ if child != os.getpgrp():
4415
+ leaders.append(child)
4416
+ continue
4417
+ except OSError:
4418
+ continue
4419
+ pids.extend(_child_pids(child))
4420
+ return leaders
4421
+
4422
+ def _stop_command(proc, formatedCMD):
4423
+ '''
4424
+ Stop a command mssh gives up on (-t, Ctrl C) as Ctrl C at a terminal would, then as mssh always did.
4425
+
4426
+ sshpass runs its command as a session of its own on a terminal of its own, and passes SIGINT on as
4427
+ a typed Ctrl C only once ssh has written there. While ssh is still connecting, any signal ends
4428
+ sshpass at once and hangs up that terminal: ssh-copy-id, whose trap does not cover SIGHUP, then left
4429
+ an empty ~/.ssh/ssh-copy-id.XXXXXXXXXX. So the command under sshpass gets the SIGINT first, as a
4430
+ typed Ctrl C would give it, and a moment to end.
4431
+ '''
4432
+ if os.name == 'posix' and _sshpass_program(formatedCMD):
4433
+ leaders = _sessions_below(proc.pid)
4434
+ for leader in leaders:
4435
+ try:
4436
+ os.killpg(leader, signal.SIGINT)
4437
+ except OSError:
4438
+ pass
4439
+ if leaders:
4440
+ try:
4441
+ proc.wait(timeout=1)
4442
+ except subprocess.TimeoutExpired:
4443
+ pass
4444
+ if proc.poll() is None:
4445
+ proc.send_signal(signal.SIGINT)
4446
+ time.sleep(0.1)
4447
+ proc.terminate()
4448
+
4381
4449
  # sshpass's own exit codes: 5 ssh asked for the password again, 6 ssh asked whether to trust a host key it
4382
4450
  # does not know, 7 ssh warned that the key known for the host's IP address differs and asked whether to go on
4383
4451
  _SSHPASS_EXIT_CODES = (5, 6, 7)
@@ -5650,6 +5718,12 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
5650
5718
  host_window.noutrefresh()
5651
5719
  host.lastPrintedUpdateTime = host.lastUpdateTime
5652
5720
  hosts_to_display, host_stats,rearrangedHosts = _get_hosts_to_display(hosts, max_num_hosts,hosts_to_display, indexOffset)
5721
+ # a host coming into a window is drawn whole: the lines noted for hosts without one would only
5722
+ # pile up, one entry per line of their output
5723
+ shown = set(hosts_to_display)
5724
+ for host in hosts:
5725
+ if host.lineNumToPrintSet and host not in shown:
5726
+ host.lineNumToPrintSet = set()
5653
5727
  if help_shown:
5654
5728
  help_window.touchwin()
5655
5729
  help_window.noutrefresh()
@@ -17,6 +17,22 @@ import pytest
17
17
 
18
18
  REPO = Path(__file__).resolve().parents[1]
19
19
  ENGINE = [sys.executable, '-I', str(REPO / 'mssh_complete.py')]
20
+ # 2026-10-13 00:00 UTC, when the histories these tests write were given their fixed times (1790000000 on)
21
+ WRITTEN = 1791849600
22
+
23
+ def aged(stamp):
24
+ '''
25
+ A fixed time from before WRITTEN, moved to be as old now as it was then. The engine weighs history
26
+ by its age on the real clock (a use halves its weight every week), so fixed times would rank
27
+ differently as the days pass: a suggestion the tests do not expect appeared 24 days on.
28
+ '''
29
+ stamp = int(stamp)
30
+ return stamp + max(0, int(time.time()) - WRITTEN) if stamp < WRITTEN else stamp
31
+
32
+ def _aged_line(line):
33
+ '''An mssh history line with its time aged.'''
34
+ stamp, tab, rest = line.partition('\t')
35
+ return '{}\t{}'.format(aged(stamp), rest) if tab and stamp.isdigit() else line
20
36
 
21
37
  def require_shell(name):
22
38
  '''The path of shell name; skip the test if it is missing, unless MSSH_REQUIRE_SHELLS lists it.'''
@@ -30,7 +46,7 @@ def require_shell(name):
30
46
 
31
47
  def make_home(tmp_path, history_lines=()):
32
48
  '''
33
- A private HOME and XDG_RUNTIME_DIR holding the given mssh history. Returns (env, home).
49
+ A private HOME and XDG_RUNTIME_DIR holding the given mssh history, its fixed times aged. Returns (env, home).
34
50
 
35
51
  The prober's lock is taken, so no prober runs (no DNS, no ping): tests that want answers about
36
52
  hosts write host-state.tsv themselves. An mssh config in HOME keeps this machine's /etc/hosts and
@@ -43,7 +59,7 @@ def make_home(tmp_path, history_lines=()):
43
59
  runtime.mkdir(mode=0o700)
44
60
  (runtime / 'multiSSH3').mkdir(mode=0o700)
45
61
  (runtime / 'multiSSH3' / 'probe.lock').write_text('')
46
- (home / '.mssh_history').write_text(''.join(line + '\n' for line in history_lines))
62
+ (home / '.mssh_history').write_text(''.join(_aged_line(line) + '\n' for line in history_lines))
47
63
  (home / '.multiSSH3.config.json').write_text(json.dumps({'DEFAULT_HOST_FILE': str(home / 'hosts'),
48
64
  'DEFAULT_ENV_FILES': [path for path in multiSSH3.DEFAULT_ENV_FILES if path.startswith('~')]}))
49
65
  env = {
@@ -895,9 +895,10 @@ def test_unreachable_members_of_an_alias_with_a_user(skipping, line, word):
895
895
  # a member written with its user keeps it, -u is for the others
896
896
  ('mssh -u root dbs uptime', ['postgres@db1', 'root@node02'], 'db1, node02'),
897
897
  ('mssh -u root dbs uptime', ['root@db1'], None),
898
- # a user typed with the alias goes in front of the member's: mssh runs root@postgres@db1
899
- ('mssh root@dbs uptime', ['root@postgres@db1'], 'db1'),
900
- ('mssh root@dbs uptime', ['root@db1'], None),
898
+ # a user typed with the alias replaces the member's: mssh runs root@db1
899
+ ('mssh root@dbs uptime', ['root@db1'], 'db1'),
900
+ ('mssh root@dbs uptime', ['postgres@db1'], None),
901
+ ('mssh root@dbs uptime', ['root@postgres@db1'], None),
901
902
  ])
902
903
  def test_unreachable_members_of_an_alias_written_with_users(skipping, tmp_path, line, failed, down):
903
904
  # dbs=postgres@db1,node02 in an env file
@@ -13,7 +13,7 @@ import time
13
13
  import pytest
14
14
 
15
15
  import mssh_complete as mc
16
- from complete_shell_harness import (ENGINE, bash_session, blesh_session, build_index, fish_session, make_home,
16
+ from complete_shell_harness import (ENGINE, aged, bash_session, blesh_session, build_index, fish_session, make_home,
17
17
  require_shell, without_command, zsh_session)
18
18
 
19
19
  HISTORY = ['17900000{:02d}\t/x/multiSSH3.py\t-su\tgpu[01-16]\t"nvidia-smi"'.format(number) for number in range(6)] + [
@@ -1845,7 +1845,7 @@ def test_fish_ignores_the_operators_functions(home, where):
1845
1845
  # lines only fish's own history has: a local command, and an mssh line
1846
1846
  data = home / '.local' / 'share' / 'fish'
1847
1847
  data.mkdir(parents=True)
1848
- (data / 'fish_history').write_text('- cmd: echo three\n when: 1790000000\n- cmd: mssh zzq9 fishonly\n when: 1790000001\n')
1848
+ (data / 'fish_history').write_text('- cmd: echo three\n when: {}\n- cmd: mssh zzq9 fishonly\n when: {}\n'.format(aged(1790000000), aged(1790000001)))
1849
1849
  config = home / '.config' / 'fish'
1850
1850
  (config / 'completions').mkdir(parents=True)
1851
1851
  (config / 'completions' / 'mssh.fish').write_text(mc.render_glue('fish', ENGINE))
@@ -10,7 +10,7 @@ import time
10
10
  import pytest
11
11
 
12
12
  import mssh_complete as mc
13
- from complete_shell_harness import (ENGINE, REPO, bash_session, build_index, fish_complete, fish_session, make_home,
13
+ from complete_shell_harness import (ENGINE, REPO, aged, bash_session, build_index, fish_complete, fish_session, make_home,
14
14
  require_shell, without_command, zsh_session)
15
15
 
16
16
  HISTORY = [
@@ -315,7 +315,7 @@ def test_fish_without_timeout_shows_nothing_but_the_completion(setup, tmp_path,
315
315
  # more shell history than a pipe holds: the slow engine never reads it, and fish must not wait to write it
316
316
  history = home / '.local' / 'share' / 'fish'
317
317
  history.mkdir(parents=True)
318
- (history / 'fish_history').write_text(''.join('- cmd: echo {} {}\n when: {}\n'.format(number, 'x' * 300, 1790000000 + number) for number in range(1000)))
318
+ (history / 'fish_history').write_text(''.join('- cmd: echo {} {}\n when: {}\n'.format(number, 'x' * 300, aged(1790000000 + number)) for number in range(1000)))
319
319
  if engine == 'answering':
320
320
  command, typed, expected = ENGINE, 'mssh gp', 'mssh gpu\\[01-16\\] '
321
321
  else:
@@ -727,3 +727,31 @@ def test_curses_print_too_small_keeps_the_keys_typed_for_the_display(stub_curses
727
727
  # the resize redraws; the keys typed meanwhile, the Esc after text among them, come back in their order
728
728
  assert len(displays) == 2 and displays[1][2:] == TOO_SMALL[:6] + ("new config",)
729
729
  assert ungot == [27, ord("y")]
730
+
731
+
732
+ def test_hosts_without_a_window_keep_no_lines_to_draw(stub_curses_harness, fake_host, monkeypatch):
733
+ # a host coming into a window is drawn whole: the lines noted while it had none were never drawn,
734
+ # and held one entry per line of its output until it came in
735
+ shown, hidden = _running_host(fake_host), _running_host(fake_host)
736
+ shown.name, hidden.name = "mock-a", "mock-b"
737
+ hidden.output = ["line {}".format(number) for number in range(1000)]
738
+ hidden.lineNumToPrintSet = set(range(1000))
739
+ polls = []
740
+
741
+ def finish_on_second_poll():
742
+ polls.append(None)
743
+ if len(polls) == 2:
744
+ shown.returncode = hidden.returncode = 0
745
+ return -1
746
+
747
+ monkeypatch.setattr(stub_curses_harness.window, "getch", finish_on_second_poll)
748
+ monkeypatch.setattr(multiSSH3.time, "perf_counter", lambda: 1.0)
749
+ monkeypatch.setattr(multiSSH3.time, "sleep", lambda seconds: None)
750
+
751
+ # room for one host window
752
+ multiSSH3.__generate_display(stub_curses_harness.window, [shown, hidden], min_char_len=80, min_line_len=20)
753
+
754
+ drawn = [arg for window in stub_curses_harness.windows for method, args, kwargs in window.calls
755
+ for arg in args if method == "addnstr" and isinstance(arg, str)]
756
+ assert any("mock-a" in text for text in drawn) and not any("mock-b" in text for text in drawn)
757
+ assert hidden.lineNumToPrintSet == set()
@@ -466,6 +466,21 @@ def test_an_alias_inside_itself_is_the_host_of_that_name(env_aliases, capsys):
466
466
  assert capsys.readouterr().err == ""
467
467
 
468
468
 
469
+ def test_a_user_typed_with_an_alias_replaces_the_user_a_member_is_written_with(env_aliases, capsys):
470
+ # root@postgres@db1 made ssh log in to db1 as the user "root@postgres": a failed login on every
471
+ # such host. Typed on the command line, root wins, as it does over ssh config's User.
472
+ known = env_aliases('dbs="postgres@db1,db2"\n', dns=["db1", "db2"])
473
+
474
+ assert multiSSH3.expand_hostnames(["root@dbs"]) == {"root@db1": known["db1"], "root@db2": known["db2"]}
475
+ assert multiSSH3.expand_hostnames(["u[1-2]@dbs"]) == {
476
+ "u1@db1": known["db1"], "u2@db1": known["db1"], "u1@db2": known["db2"], "u2@db2": known["db2"]}
477
+ assert multiSSH3.expand_hostnames(["dbs"]) == {"postgres@db1": known["db1"], "db2": known["db2"]}
478
+ # without DNS the same names
479
+ assert multiSSH3.expand_hostnames_local(["root@dbs"])[0] == ["root@db1", "root@db2"]
480
+ assert multiSSH3.expand_hostnames_local(["dbs"])[0] == ["db2", "postgres@db1"]
481
+ assert capsys.readouterr().err == ""
482
+
483
+
469
484
  def test_alias_values_are_split_like_the_hosts_argument(env_aliases, monkeypatch, capsys):
470
485
  monkeypatch.setattr(multiSSH3, "_no_env", False)
471
486
  monkeypatch.setenv("MSSH_TEST_SPACED", "127.0.0.7 127.0.0.8")
@@ -0,0 +1,287 @@
1
+ """
2
+ -t and Ctrl C stop only mssh's local ssh (or rsync, scp, ...): the remote command gets no signal and
3
+ may run on. -t fires after that many seconds without output. A host mssh stopped says so, and what
4
+ stopping it left; one stopped by Ctrl C is never a success (sshpass exits 0 when interrupted). The
5
+ command sshpass runs gets the SIGINT first, as Ctrl C at a terminal gives it: ssh-copy-id cleans up.
6
+ """
7
+ import io
8
+ import os
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ import time
15
+
16
+ import pytest
17
+
18
+ import multiSSH3
19
+
20
+
21
+ class RunningProc:
22
+ """A command that runs until it is terminated."""
23
+ def __init__(self):
24
+ # no process: none to look for below it
25
+ self.pid = None
26
+ self.stdout = io.BytesIO()
27
+ self.stderr = io.BytesIO()
28
+ self.stdin = io.BytesIO()
29
+ self.returncode = None
30
+ self.signals = []
31
+
32
+ def poll(self):
33
+ return self.returncode
34
+
35
+ def communicate(self, *args, **kwargs):
36
+ return b"", b""
37
+
38
+ def send_signal(self, sig):
39
+ self.signals.append(sig)
40
+
41
+ def terminate(self):
42
+ self.returncode = -15
43
+
44
+
45
+ class EndedProc(RunningProc):
46
+ """A command that has already ended when mssh looks."""
47
+ def __init__(self, returncode, stderr=b""):
48
+ super().__init__()
49
+ self.returncode = returncode
50
+ self.stderr = io.BytesIO(stderr)
51
+
52
+
53
+ def _run(fake_host, monkeypatch, proc, timeout=0, passwds=None, ctrl_c=False, **host_kwargs):
54
+ monkeypatch.setattr(multiSSH3, "_binPaths", {"ssh": "/usr/bin/ssh", "sshpass": "/usr/bin/sshpass", "rsync": "/usr/bin/rsync", "scp": "/usr/bin/scp"})
55
+ monkeypatch.setattr(multiSSH3, "_emo", False)
56
+
57
+ def start(*args, **kwargs):
58
+ # with ctrl_c, Ctrl C comes once the command runs (one pressed before would keep it from starting)
59
+ if ctrl_c:
60
+ multiSSH3._emo = True
61
+ return proc
62
+ monkeypatch.setattr(multiSSH3.subprocess, "Popen", start)
63
+ host = fake_host("mock-a", **host_kwargs)
64
+ # the command starts at 0 and is silent from then on
65
+ ticks = iter([0.0, 2.0])
66
+ monkeypatch.setattr(multiSSH3.time, "monotonic", lambda: next(ticks, 2.0))
67
+ monkeypatch.setattr(multiSSH3.time, "sleep", lambda seconds: None)
68
+ multiSSH3.run_command(host, threading.Semaphore(1), timeout, passwds)
69
+ return host
70
+
71
+
72
+ def test_a_timeout_says_the_command_may_still_run_on_the_host(fake_host, monkeypatch):
73
+ host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="sleep 100")
74
+
75
+ assert host.returncode == 124
76
+ assert host.stderr == ["Timeout!", "No output for 1 s, so ssh was stopped: the command may still run on the host."]
77
+ assert "No output for 1 s, so ssh was stopped: the command may still run on the host." in host.output
78
+
79
+
80
+ def test_a_timeout_with_a_password_names_ssh_not_sshpass(fake_host, monkeypatch):
81
+ host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, passwds="pw", command="sleep 100")
82
+
83
+ assert host.stderr[-1] == "No output for 1 s, so ssh was stopped: the command may still run on the host."
84
+
85
+
86
+ def test_a_copy_that_timed_out_may_be_incomplete(fake_host, monkeypatch):
87
+ # scp: a failed rsync may be tried again with scp
88
+ host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="/tmp/dest", files=["/tmp/a"], scp=True)
89
+
90
+ assert host.returncode == 124
91
+ assert host.stderr == ["Timeout!", "No output for 1 s, so scp was stopped: the copy may be incomplete."]
92
+
93
+
94
+ def test_ctrl_c_while_the_command_runs(fake_host, monkeypatch):
95
+ proc = RunningProc()
96
+
97
+ host = _run(fake_host, monkeypatch, proc, command="sleep 100", ctrl_c=True)
98
+
99
+ assert proc.signals == [multiSSH3.signal.SIGINT]
100
+ assert host.returncode == 130
101
+ assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
102
+
103
+
104
+ def test_ctrl_c_that_reached_ssh_first(fake_host, monkeypatch):
105
+ # the terminal's Ctrl C reaches ssh as well as mssh: ssh ends before mssh stops it, saying only
106
+ # "Killed by signal 2." (hidden)
107
+
108
+ host = _run(fake_host, monkeypatch, EndedProc(255, b"Killed by signal 2.\n"), command="sleep 100", ctrl_c=True)
109
+
110
+ assert host.returncode == 130
111
+ assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
112
+
113
+
114
+ def test_ctrl_c_with_sshpass_is_not_a_success(fake_host, monkeypatch):
115
+ # sshpass exits 0 when Ctrl C stops it
116
+
117
+ host = _run(fake_host, monkeypatch, EndedProc(0), passwds="pw", command="sleep 100", ctrl_c=True)
118
+
119
+ assert host.returncode == 130
120
+ assert host.stderr[-1] == "ssh was stopped: the command may still run on the host."
121
+
122
+
123
+ def test_a_command_that_ends_on_its_own_keeps_its_result(fake_host, monkeypatch):
124
+ host = _run(fake_host, monkeypatch, EndedProc(3, b"real error\n"), timeout=1, command="false")
125
+
126
+ assert host.returncode == 3
127
+ assert host.stderr == ["real error"]
128
+
129
+
130
+ def test_the_timeout_help_says_it_counts_time_without_output():
131
+ help_text = next(action.help for action in multiSSH3.get_parser()._actions if "--timeout" in action.option_strings)
132
+
133
+ assert "without output" in help_text
134
+ assert "Timeout for each command" not in help_text
135
+
136
+
137
+ posix_only = pytest.mark.skipif(os.name != "posix", reason="sessions and terminals are POSIX")
138
+
139
+ # sshpass 1.10 while ssh has written nothing to sshpass's terminal yet: the command runs as a session
140
+ # leader on a new terminal, and SIGINT or SIGTERM ends sshpass at once, which hangs up that terminal
141
+ FAKE_SSHPASS = """#!{python}
142
+ import os, pty, signal, sys
143
+ pid, master = pty.fork()
144
+ if pid == 0:
145
+ os.execv(sys.argv[2], sys.argv[2:])
146
+ signal.signal(signal.SIGINT, lambda *_: os._exit(0))
147
+ signal.signal(signal.SIGTERM, lambda *_: os._exit(0))
148
+ while True:
149
+ try:
150
+ if not os.read(master, 1024):
151
+ break
152
+ except OSError:
153
+ break
154
+ _, status = os.waitpid(pid, 0)
155
+ sys.exit(status >> 8)
156
+ """
157
+
158
+ # ssh-copy-id's scratch directory and the trap that removes it (not on SIGHUP); sleep is ssh connecting
159
+ FAKE_SSH_COPY_ID = """#!/bin/sh
160
+ SCRATCH_DIR=$(mktemp -d ~/.ssh/ssh-copy-id.XXXXXXXXXX)
161
+ trap 'rm -rf "$SCRATCH_DIR"; echo cleaned >> ~/cleaned' EXIT TERM INT QUIT
162
+ sleep 30
163
+ """
164
+
165
+
166
+ def _tool(path, text):
167
+ path.write_text(text)
168
+ path.chmod(0o755)
169
+ return str(path)
170
+
171
+
172
+ @pytest.fixture
173
+ def copy_id_that_hangs(monkeypatch, tmp_path):
174
+ home = tmp_path / "home"
175
+ (home / ".ssh").mkdir(parents=True)
176
+ monkeypatch.setenv("HOME", str(home))
177
+ bins = tmp_path / "bin"
178
+ bins.mkdir()
179
+ monkeypatch.setattr(multiSSH3, "_binPaths", {
180
+ "sh": "/bin/sh",
181
+ "ssh": str(bins / "ssh"),
182
+ "sshpass": _tool(bins / "sshpass", FAKE_SSHPASS.format(python=sys.executable)),
183
+ "ssh-copy-id": _tool(bins / "ssh-copy-id", FAKE_SSH_COPY_ID),
184
+ })
185
+ monkeypatch.setattr(multiSSH3, "getIP", lambda hostname, local=False: "127.0.0.1")
186
+ monkeypatch.setattr(multiSSH3, "_ssh_config_values", lambda hostname: {"proxyjump": "none", "proxycommand": "none"})
187
+ monkeypatch.setattr(multiSSH3, "_unavailable_hosts_file_path", lambda create=True: str(tmp_path / "unavailable.csv"))
188
+ monkeypatch.setattr(multiSSH3, "__mainReturnCode", 0)
189
+ monkeypatch.setattr(multiSSH3, "__failedHosts", set())
190
+ return home
191
+
192
+
193
+ @posix_only
194
+ def test_a_timed_out_ssh_copy_id_under_sshpass_cleans_up_after_itself(copy_id_that_hangs):
195
+ # sshpass ended at once and hung up ssh-copy-id, whose trap does not cover SIGHUP: every host that
196
+ # timed out left an empty ~/.ssh/ssh-copy-id.XXXXXXXXXX
197
+ home = copy_id_that_hangs
198
+ start = time.monotonic()
199
+
200
+ hosts = multiSSH3.run_command_on_hosts(hosts="mock-a", copy_id=True, password="pw", timeout=1,
201
+ no_watch=True, quiet=True, no_history=True, called=True, will_update_unreachable_hosts=False)
202
+
203
+ assert [host.returncode for host in hosts] == [124]
204
+ deadline = time.monotonic() + 5
205
+ while not (home / "cleaned").exists() and time.monotonic() < deadline:
206
+ time.sleep(0.05)
207
+ assert list((home / ".ssh").glob("ssh-copy-id.*")) == []
208
+ assert (home / "cleaned").exists()
209
+ # stopped, not left to sleep out its 30 s
210
+ assert time.monotonic() - start < 10
211
+
212
+
213
+ @posix_only
214
+ def test_ssh_copy_id_under_sshpass_stopped_by_ctrl_c_cleans_up_after_itself(copy_id_that_hangs, monkeypatch):
215
+ home = copy_id_that_hangs
216
+ monkeypatch.setattr(multiSSH3, "_emo", False)
217
+ monkeypatch.setattr(multiSSH3, "_stopping", False)
218
+ # the first Ctrl C, once ssh-copy-id is waiting for ssh
219
+ threading.Timer(0.5, lambda: setattr(multiSSH3, "_emo", True)).start()
220
+
221
+ hosts = multiSSH3.run_command_on_hosts(hosts="mock-a", copy_id=True, password="pw",
222
+ no_watch=True, quiet=True, no_history=True, called=True, will_update_unreachable_hosts=False)
223
+
224
+ assert [host.returncode for host in hosts] == [130]
225
+ deadline = time.monotonic() + 5
226
+ while not (home / "cleaned").exists() and time.monotonic() < deadline:
227
+ time.sleep(0.05)
228
+ assert list((home / ".ssh").glob("ssh-copy-id.*")) == []
229
+
230
+
231
+ @posix_only
232
+ @pytest.mark.skipif(not shutil.which("pgrep"), reason="pgrep is the fallback")
233
+ def test_child_processes_are_found_without_proc_children_files(monkeypatch):
234
+ child = subprocess.Popen(["sleep", "30"])
235
+ try:
236
+ def no_children_file(path, *args, **kwargs):
237
+ raise FileNotFoundError(path)
238
+ monkeypatch.setattr(multiSSH3, "open", no_children_file, raising=False)
239
+ assert child.pid in multiSSH3._child_pids(os.getpid())
240
+ finally:
241
+ child.kill()
242
+ child.wait()
243
+
244
+
245
+ @posix_only
246
+ def test_sessions_below_a_process_are_found(tmp_path):
247
+ # sh -> python (setsid) -> sleep: the python process leads a session, as sshpass's command does
248
+ ready = tmp_path / "ready"
249
+ script = 'import os, time; os.setsid(); open({!r}, "w").close(); time.sleep(30)'.format(str(ready))
250
+ proc = subprocess.Popen(["/bin/sh", "-c", "{} -c {}; true".format(shlex.quote(sys.executable), shlex.quote(script))])
251
+ try:
252
+ deadline = time.monotonic() + 5
253
+ while not ready.exists() and time.monotonic() < deadline:
254
+ time.sleep(0.02)
255
+ leaders = multiSSH3._sessions_below(proc.pid)
256
+ assert len(leaders) == 1 and leaders[0] != proc.pid
257
+ assert os.getsid(leaders[0]) == leaders[0]
258
+ finally:
259
+ for leader in multiSSH3._sessions_below(proc.pid):
260
+ os.killpg(leader, 9)
261
+ proc.kill()
262
+ proc.wait()
263
+
264
+
265
+ def test_a_copy_id_that_timed_out_names_ssh_copy_id(monkeypatch):
266
+ # --copy_id runs sshpass in an sh -c line: the note named sh. Stopped, ssh-copy-id cleans up and goes
267
+ # on, saying "All keys were skipped because they already exist on the remote system."
268
+ monkeypatch.setattr(multiSSH3, "_binPaths", {"sh": "/bin/sh", "sshpass": "/usr/bin/sshpass", "ssh-copy-id": "/usr/bin/ssh-copy-id"})
269
+ line = ["/bin/sh", "-c", "/usr/bin/sshpass -e /usr/bin/ssh-copy-id -o ConnectTimeout=30 bob@mock-a"]
270
+
271
+ assert multiSSH3._stopped_command_note(line) == "ssh-copy-id was stopped: the key may not be installed"
272
+
273
+
274
+ @posix_only
275
+ def test_no_session_is_looked_for_below_init_or_no_process():
276
+ # pgrep -P 0 lists init: its group must never get mssh's SIGINT
277
+ assert multiSSH3._sessions_below(0) == []
278
+ assert multiSSH3._sessions_below(1) == []
279
+ assert multiSSH3._sessions_below(None) == []
280
+
281
+
282
+ def test_a_timed_out_ssh_found_under_another_name_is_still_ssh(monkeypatch):
283
+ # Windows finds ssh as ...\ssh.EXE: mssh knows it by the path it found
284
+ monkeypatch.setattr(multiSSH3, "_binPaths", {"ssh": "/opt/openssh/ssh.EXE", "scp": "/opt/openssh/scp.EXE"})
285
+
286
+ assert multiSSH3._stopped_command_note(["/opt/openssh/ssh.EXE", "mock-a", "uptime"]) == "ssh was stopped: the command may still run on the host"
287
+ assert multiSSH3._stopped_command_note(["/opt/openssh/scp.EXE", "a", "mock-a:b"]) == "scp was stopped: the copy may be incomplete"
@@ -1,123 +0,0 @@
1
- """
2
- -t and Ctrl C stop only mssh's local ssh (or rsync, scp, ...): the remote command gets no signal and
3
- may run on. -t fires after that many seconds without output. A host mssh stopped says so, and what
4
- stopping it left; one stopped by Ctrl C is never a success (sshpass exits 0 when interrupted).
5
- """
6
- import io
7
- import threading
8
-
9
- import multiSSH3
10
-
11
-
12
- class RunningProc:
13
- """A command that runs until it is terminated."""
14
- def __init__(self):
15
- self.stdout = io.BytesIO()
16
- self.stderr = io.BytesIO()
17
- self.stdin = io.BytesIO()
18
- self.returncode = None
19
- self.signals = []
20
-
21
- def poll(self):
22
- return self.returncode
23
-
24
- def communicate(self, *args, **kwargs):
25
- return b"", b""
26
-
27
- def send_signal(self, sig):
28
- self.signals.append(sig)
29
-
30
- def terminate(self):
31
- self.returncode = -15
32
-
33
-
34
- class EndedProc(RunningProc):
35
- """A command that has already ended when mssh looks."""
36
- def __init__(self, returncode, stderr=b""):
37
- super().__init__()
38
- self.returncode = returncode
39
- self.stderr = io.BytesIO(stderr)
40
-
41
-
42
- def _run(fake_host, monkeypatch, proc, timeout=0, passwds=None, ctrl_c=False, **host_kwargs):
43
- monkeypatch.setattr(multiSSH3, "_binPaths", {"ssh": "/usr/bin/ssh", "sshpass": "/usr/bin/sshpass", "rsync": "/usr/bin/rsync", "scp": "/usr/bin/scp"})
44
- monkeypatch.setattr(multiSSH3, "_emo", False)
45
-
46
- def start(*args, **kwargs):
47
- # with ctrl_c, Ctrl C comes once the command runs (one pressed before would keep it from starting)
48
- if ctrl_c:
49
- multiSSH3._emo = True
50
- return proc
51
- monkeypatch.setattr(multiSSH3.subprocess, "Popen", start)
52
- host = fake_host("mock-a", **host_kwargs)
53
- # the command starts at 0 and is silent from then on
54
- ticks = iter([0.0, 2.0])
55
- monkeypatch.setattr(multiSSH3.time, "monotonic", lambda: next(ticks, 2.0))
56
- monkeypatch.setattr(multiSSH3.time, "sleep", lambda seconds: None)
57
- multiSSH3.run_command(host, threading.Semaphore(1), timeout, passwds)
58
- return host
59
-
60
-
61
- def test_a_timeout_says_the_command_may_still_run_on_the_host(fake_host, monkeypatch):
62
- host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="sleep 100")
63
-
64
- assert host.returncode == 124
65
- assert host.stderr == ["Timeout!", "No output for 1 s, so ssh was stopped: the command may still run on the host."]
66
- assert "No output for 1 s, so ssh was stopped: the command may still run on the host." in host.output
67
-
68
-
69
- def test_a_timeout_with_a_password_names_ssh_not_sshpass(fake_host, monkeypatch):
70
- host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, passwds="pw", command="sleep 100")
71
-
72
- assert host.stderr[-1] == "No output for 1 s, so ssh was stopped: the command may still run on the host."
73
-
74
-
75
- def test_a_copy_that_timed_out_may_be_incomplete(fake_host, monkeypatch):
76
- # scp: a failed rsync may be tried again with scp
77
- host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="/tmp/dest", files=["/tmp/a"], scp=True)
78
-
79
- assert host.returncode == 124
80
- assert host.stderr == ["Timeout!", "No output for 1 s, so scp was stopped: the copy may be incomplete."]
81
-
82
-
83
- def test_ctrl_c_while_the_command_runs(fake_host, monkeypatch):
84
- proc = RunningProc()
85
-
86
- host = _run(fake_host, monkeypatch, proc, command="sleep 100", ctrl_c=True)
87
-
88
- assert proc.signals == [multiSSH3.signal.SIGINT]
89
- assert host.returncode == 130
90
- assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
91
-
92
-
93
- def test_ctrl_c_that_reached_ssh_first(fake_host, monkeypatch):
94
- # the terminal's Ctrl C reaches ssh as well as mssh: ssh ends before mssh stops it, saying only
95
- # "Killed by signal 2." (hidden)
96
-
97
- host = _run(fake_host, monkeypatch, EndedProc(255, b"Killed by signal 2.\n"), command="sleep 100", ctrl_c=True)
98
-
99
- assert host.returncode == 130
100
- assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
101
-
102
-
103
- def test_ctrl_c_with_sshpass_is_not_a_success(fake_host, monkeypatch):
104
- # sshpass exits 0 when Ctrl C stops it
105
-
106
- host = _run(fake_host, monkeypatch, EndedProc(0), passwds="pw", command="sleep 100", ctrl_c=True)
107
-
108
- assert host.returncode == 130
109
- assert host.stderr[-1] == "ssh was stopped: the command may still run on the host."
110
-
111
-
112
- def test_a_command_that_ends_on_its_own_keeps_its_result(fake_host, monkeypatch):
113
- host = _run(fake_host, monkeypatch, EndedProc(3, b"real error\n"), timeout=1, command="false")
114
-
115
- assert host.returncode == 3
116
- assert host.stderr == ["real error"]
117
-
118
-
119
- def test_the_timeout_help_says_it_counts_time_without_output():
120
- help_text = next(action.help for action in multiSSH3.get_parser()._actions if "--timeout" in action.option_strings)
121
-
122
- assert "without output" in help_text
123
- assert "Timeout for each command" not in help_text
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes