multiCMD 1.49__tar.gz → 1.50__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.49
3
+ Version: 1.50
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -206,6 +206,12 @@ print(ex.get_return_codes())
206
206
  print(ex.get_results())
207
207
  ```
208
208
 
209
+ `run_command(s)` returns right away and queues the commands. One dispatcher thread
210
+ starts each queued command only after taking a permit from the semaphore, so the
211
+ executor holds at most one thread per permit plus the dispatcher, however many
212
+ commands are queued. A queued command's `task.thread` is `None` until it starts,
213
+ and `stop()` skips commands that have not started yet.
214
+
209
215
  ### Range expansion from Python
210
216
 
211
217
  ```python
@@ -180,6 +180,12 @@ print(ex.get_return_codes())
180
180
  print(ex.get_results())
181
181
  ```
182
182
 
183
+ `run_command(s)` returns right away and queues the commands. One dispatcher thread
184
+ starts each queued command only after taking a permit from the semaphore, so the
185
+ executor holds at most one thread per permit plus the dispatcher, however many
186
+ commands are queued. A queued command's `task.thread` is `None` until it starts,
187
+ and `stop()` skips commands that have not started yet.
188
+
183
189
  ### Range expansion from Python
184
190
 
185
191
  ```python
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.49
3
+ Version: 1.50
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -206,6 +206,12 @@ print(ex.get_return_codes())
206
206
  print(ex.get_results())
207
207
  ```
208
208
 
209
+ `run_command(s)` returns right away and queues the commands. One dispatcher thread
210
+ starts each queued command only after taking a permit from the semaphore, so the
211
+ executor holds at most one thread per permit plus the dispatcher, however many
212
+ commands are queued. A queued command's `task.thread` is `None` until it starts,
213
+ and `stop()` skips commands that have not started yet.
214
+
209
215
  ### Range expansion from Python
210
216
 
211
217
  ```python
@@ -7,6 +7,7 @@
7
7
  # ///
8
8
  #%% imports
9
9
  import argparse
10
+ import collections
10
11
  import itertools
11
12
  import math
12
13
  import os
@@ -22,10 +23,14 @@ import time
22
23
  from pprint import pformat
23
24
 
24
25
  #%% global vars
25
- version = '1.49'
26
+ version = '1.50'
26
27
  __version__ = version
27
- COMMIT_DATE = '2026-08-10'
28
+ COMMIT_DATE = '2026-10-09'
29
+ # fire-and-forget threads (run_commands with wait_for_return=False) that join_threads() waits on
28
30
  __running_threads = set()
31
+ __running_threads_lock = threading.Lock()
32
+ # fire-and-forget threads append themselves here when done, so they get joined and dropped from __running_threads
33
+ __finished_threads = collections.deque()
29
34
  __variables = {}
30
35
 
31
36
  # immutable helpers compiled once at import time
@@ -69,10 +74,30 @@ stop={self.stop!r})"""
69
74
  def is_alive(self):
70
75
  return self.thread is not None and self.thread.is_alive()
71
76
 
77
+ class _HeldPermit:
78
+ '''
79
+ Semaphore stand-in for a command whose permit the dispatcher already holds ( internal )
80
+ Entering is free, exiting releases the permit.
81
+ '''
82
+ def __init__(self, semaphore):
83
+ self.semaphore = semaphore
84
+
85
+ def __enter__(self):
86
+ return self
87
+
88
+ def __exit__(self, *exc_info):
89
+ self.semaphore.release()
90
+ return False
91
+
72
92
  class AsyncExecutor:
73
93
  def __init__(self, max_threads=1,semaphore=...,timeout=0,quiet=True,dry_run=False,parse=False):
74
94
  '''
75
95
  AsyncExecutor class to run commands in parallel asynchronously
96
+
97
+ Commands are queued and a dispatcher thread starts each one only after taking a permit
98
+ from the semaphore, so the executor holds at most one thread per permit it uses plus the
99
+ dispatcher, however many commands are queued. A queued command's task.thread stays None
100
+ until it starts.
76
101
  @params:
77
102
  max_threads: The maximum number of threads to use ( int ) ( Note: if passing semaphore, this likely will be ignored )
78
103
  semaphore: The semaphore to use for threading ( threading.Semaphore )
@@ -85,13 +110,19 @@ class AsyncExecutor:
85
110
  if semaphore is ...:
86
111
  semaphore = threading.Semaphore(max_threads)
87
112
  self.semaphore = semaphore
88
- self.runningThreads = []
89
113
  self.tasks = []
90
114
  self.timeout = timeout
91
115
  self.quiet = quiet
92
116
  self.dry_run = dry_run
93
117
  self.parse = parse
94
- self.__lastNotJoined = 0
118
+ self._lock = threading.Lock()
119
+ # (task, sem, timeout, quiet, dry_run) waiting for a permit
120
+ self._queue = collections.deque()
121
+ self._dispatching = False
122
+ # started threads not yet joined, in start order ( dict used as an ordered set )
123
+ self._threads = {}
124
+ # threads append themselves here when done, so _reap() joins them without scanning
125
+ self._finished = collections.deque()
95
126
 
96
127
  def __iter__(self):
97
128
  return iter(self.tasks)
@@ -107,10 +138,82 @@ class AsyncExecutor:
107
138
 
108
139
  def __bool__(self):
109
140
  return bool(self.tasks)
110
-
141
+
142
+ @property
143
+ def runningThreads(self):
144
+ '''
145
+ The threads that have not finished yet: the dispatcher while commands are queued, and the started commands
146
+ @returns:
147
+ list[threading.Thread]: The unfinished threads
148
+ '''
149
+ self._reap()
150
+ with self._lock:
151
+ return list(self._threads)
152
+
153
+ def _reap(self):
154
+ '''
155
+ Join the threads that finished and forget them ( internal )
156
+ Joining releases the thread's stack: a finished thread that is still referenced but never
157
+ joined keeps its stack mapped.
158
+ '''
159
+ while True:
160
+ try:
161
+ thread = self._finished.popleft()
162
+ except IndexError:
163
+ return
164
+ thread.join()
165
+ with self._lock:
166
+ self._threads.pop(thread, None)
167
+
168
+ def _start_thread(self, target, args):
169
+ thread = threading.Thread(target=target, args=args, daemon=True)
170
+ # registered before it starts, so it is tracked before it can report itself finished
171
+ with self._lock:
172
+ self._threads[thread] = None
173
+ try:
174
+ thread.start()
175
+ except BaseException:
176
+ # never started: it would never report itself finished
177
+ del self._threads[thread]
178
+ raise
179
+ return thread
180
+
181
+ def _dispatch(self):
182
+ '''
183
+ Start queued commands one permit at a time ( internal )
184
+ '''
185
+ try:
186
+ while True:
187
+ with self._lock:
188
+ if not self._queue:
189
+ self._dispatching = False
190
+ break
191
+ task, sem, timeout, quiet, dry_run = self._queue.popleft()
192
+ # wait for a permit, but give up on a command that gets stopped meanwhile
193
+ acquired = False
194
+ while not task.stop and not acquired:
195
+ acquired = sem.acquire(timeout=0.1)
196
+ if task.stop:
197
+ if acquired:
198
+ sem.release()
199
+ continue
200
+ self._reap()
201
+ try:
202
+ task.thread = self._start_thread(_run_executor_task, (task, _HeldPermit(sem), timeout, quiet, dry_run, self._finished))
203
+ except BaseException:
204
+ sem.release()
205
+ raise
206
+ except BaseException:
207
+ # let the next run_commands() start a new dispatcher for what is still queued
208
+ with self._lock:
209
+ self._dispatching = False
210
+ raise
211
+ finally:
212
+ self._finished.append(threading.current_thread())
213
+
111
214
  def run_commands(self, commands, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
112
215
  '''
113
- Run multiple commands in parallel asynchronously
216
+ Run multiple commands in parallel asynchronously. Returns right away, the commands are queued
114
217
  @params:
115
218
  commands: A list of commands to run ( list[str] | list[list[str]] )
116
219
  timeout: The timeout for each command to override the object default
@@ -134,19 +237,26 @@ class AsyncExecutor:
134
237
  parse = self.parse
135
238
  if sem is ...:
136
239
  sem = self.semaphore
137
- if len(self.runningThreads) > 130000:
138
- self.wait(timeout=0)
139
- if len(self.runningThreads) > 130000:
140
- print('The amount of running threads approching cpython limit of 130704. Waiting until some available.')
141
- while len(self.runningThreads) > 120000:
142
- self.wait(timeout=1)
143
- elif len(self.runningThreads) + self.__lastNotJoined > 1000:
144
- self.wait(timeout=0)
145
- self.__lastNotJoined = len(self.runningThreads)
146
- taskObjects: list[Task] = run_commands(commands,timeout=timeout,max_threads=max_threads,quiet=quiet,dry_run=dry_run,with_stdErr=False,
147
- return_code_only=False,return_object=True, parse = parse, wait_for_return = False, sem = sem)
148
- self.tasks.extend(taskObjects)
149
- self.runningThreads.extend([task.thread for task in taskObjects])
240
+ taskObjects, use_sudo = _build_tasks(commands, parse=parse)
241
+ if not sem:
242
+ sem = threading.Semaphore(max_threads if max_threads >= 1 else max(len(taskObjects), 1))
243
+ if use_sudo:
244
+ _validate_sudo(sem)
245
+ self._reap()
246
+ with self._lock:
247
+ self.tasks.extend(taskObjects)
248
+ self._queue.extend((task, sem, timeout, quiet, dry_run) for task in taskObjects)
249
+ start_dispatcher = not self._dispatching and bool(self._queue)
250
+ if start_dispatcher:
251
+ # only the dispatcher clears this, under the lock, once it sees the queue empty
252
+ self._dispatching = True
253
+ if start_dispatcher:
254
+ try:
255
+ self._start_thread(self._dispatch, ())
256
+ except BaseException:
257
+ with self._lock:
258
+ self._dispatching = False
259
+ raise
150
260
  return taskObjects
151
261
 
152
262
  def run_command(self, command, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
@@ -169,26 +279,30 @@ class AsyncExecutor:
169
279
  '''
170
280
  Wait for the threads to finish
171
281
  @params:
172
- timeout: The timeout for each command to override the object default
173
- threads: The threads to join, default to all running threads managed by this object
282
+ timeout: The timeout for each thread to override the object default ( negative waits forever )
283
+ threads: The threads to join, default to all running threads managed by this object,
284
+ including the ones the dispatcher starts while waiting
174
285
  @returns:
175
286
  list[threading.Thread]: The list of running threads that are still running
176
287
  '''
177
- if threads is ...:
178
- threads = self.runningThreads
179
288
  if timeout is ...:
180
289
  timeout = self.timeout
181
- for thread in threads:
182
- if timeout >= 0:
183
- thread.join(timeout=timeout)
184
- else:
185
- thread.join()
186
- self.runningThreads = [thread for thread in self.runningThreads if thread.is_alive()]
290
+ while True:
291
+ pending = self.runningThreads if threads is ... else list(threads)
292
+ for thread in pending:
293
+ if timeout >= 0:
294
+ thread.join(timeout=timeout)
295
+ else:
296
+ thread.join()
297
+ # once a pass finishes every thread, look again for commands the dispatcher started meanwhile
298
+ if threads is not ... or not pending or any(thread.is_alive() for thread in pending):
299
+ break
187
300
  return self.runningThreads
188
301
 
189
302
  def stop(self,timeout=...):
190
303
  '''
191
- Stop all running threads. This signals all threads to stop and joins them
304
+ Stop all running threads. This signals all threads to stop and joins them.
305
+ Commands still queued are skipped: they keep task.thread None and returncode None
192
306
  @params:
193
307
  None
194
308
  @returns:
@@ -208,8 +322,10 @@ class AsyncExecutor:
208
322
  list[Task]: The list of tasks that are managed by this object
209
323
  '''
210
324
  self.stop(timeout)
211
- self.tasks = []
212
- self.runningThreads = []
325
+ with self._lock:
326
+ self.tasks = []
327
+ self._queue.clear()
328
+ self._threads.clear()
213
329
  return self.tasks
214
330
 
215
331
  def join(self, timeout=..., threads = ..., print_error=True):
@@ -578,6 +694,69 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
578
694
  else:
579
695
  return task.stdout
580
696
 
697
+ def _run_executor_task(task, permit, timeout, quiet, dry_run, finished):
698
+ '''
699
+ Run an AsyncExecutor command, then report this thread finished so the executor joins it ( internal )
700
+ '''
701
+ try:
702
+ __run_command(task, permit, timeout, quiet, dry_run, identity=...)
703
+ finally:
704
+ finished.append(threading.current_thread())
705
+
706
+ def __run_tracked_command(task, sem, timeout, quiet, dry_run, identity):
707
+ '''
708
+ Run a fire-and-forget command, then report this thread finished so it leaves __running_threads ( internal )
709
+ '''
710
+ try:
711
+ __run_command(task, sem, timeout, quiet, dry_run, identity=identity)
712
+ finally:
713
+ __finished_threads.append(threading.current_thread())
714
+
715
+ def __reap_finished_threads():
716
+ '''
717
+ Join finished fire-and-forget threads and drop them from __running_threads ( internal )
718
+ Joining releases the thread's stack: a finished thread that is still referenced but never
719
+ joined keeps its stack mapped.
720
+ '''
721
+ while True:
722
+ try:
723
+ thread = __finished_threads.popleft()
724
+ except IndexError:
725
+ return
726
+ thread.join()
727
+ with __running_threads_lock:
728
+ __running_threads.discard(thread)
729
+
730
+ def _build_tasks(commands, parse=False, use_sudo=...):
731
+ '''
732
+ Format commands into Tasks, prefixing sudo when requested and available ( internal )
733
+
734
+ @returns:
735
+ tuple[list[Task], bool]: The tasks and whether sudo is used
736
+ '''
737
+ global USE_SUDO
738
+ global SUDO_PATH
739
+ if use_sudo is ...:
740
+ use_sudo = USE_SUDO
741
+ # Guard against requesting sudo when it is not available. Without this,
742
+ # prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
743
+ if use_sudo and not SUDO_PATH:
744
+ print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
745
+ use_sudo = False
746
+ # split the commands in commands if it is a string
747
+ formatedCommands = []
748
+ for command in commands:
749
+ formatedCommands.extend(__format_command(command,expand=parse))
750
+ if use_sudo:
751
+ formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
752
+ return [Task(command) for command in formatedCommands], bool(use_sudo)
753
+
754
+ def _validate_sudo(sem):
755
+ '''
756
+ Validate sudo access before running sudo commands ( internal )
757
+ '''
758
+ __run_command(Task([SUDO_PATH, '-v']),sem,timeout=60,quiet=True,dry_run=False,with_stdErr=False,identity=None)
759
+
581
760
  def __format_command(command,expand = False):
582
761
  '''
583
762
  Format a command
@@ -714,41 +893,31 @@ def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,wit
714
893
  @returns:
715
894
  list: The output of the commands ( list[None] | list[int] | list[list[str]] | list[Task] | None )
716
895
  '''
717
- global USE_SUDO
718
- global SUDO_PATH
719
- if use_sudo is ...:
720
- use_sudo = USE_SUDO
721
- # Guard against requesting sudo when it is not available. Without this,
722
- # prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
723
- if use_sudo and not SUDO_PATH:
724
- print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
725
- use_sudo = False
726
- # split the commands in commands if it is a string
727
- formatedCommands = []
728
- for command in commands:
729
- formatedCommands.extend(__format_command(command,expand=parse))
730
896
  # initialize the tasks
731
- if use_sudo:
732
- formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
733
- tasks = [Task(command) for command in formatedCommands]
897
+ tasks, use_sudo = _build_tasks(commands, parse=parse, use_sudo=use_sudo)
734
898
  # run the tasks with max_threads. if max_threads is 0, use the number of commands
735
899
  if max_threads < 1:
736
- max_threads = len(formatedCommands)
900
+ max_threads = len(tasks)
737
901
  if not sem:
738
902
  sem = threading.Semaphore(max_threads) # Limit concurrent sessions
739
903
  if use_sudo:
740
- # validate sudo access
741
- __run_command(Task([SUDO_PATH, '-v']),sem,timeout=60,quiet=True,dry_run=False,with_stdErr=False,identity=None)
742
- if max_threads > 1 or not wait_for_return:
904
+ _validate_sudo(sem)
905
+ if not wait_for_return:
906
+ __reap_finished_threads()
907
+ threads = [threading.Thread(target=__run_tracked_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
908
+ # tracked before they start, so a thread is always registered before it reports itself finished
909
+ with __running_threads_lock:
910
+ __running_threads.update(threads)
911
+ for thread,task in zip(threads,tasks):
912
+ task.thread = thread
913
+ thread.start()
914
+ elif max_threads > 1:
743
915
  threads = [threading.Thread(target=__run_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
744
916
  for thread,task in zip(threads,tasks):
745
917
  task.thread = thread
746
918
  thread.start()
747
- if wait_for_return:
748
- for thread in threads:
749
- thread.join()
750
- else:
751
- __running_threads.update(threads)
919
+ for thread in threads:
920
+ thread.join()
752
921
  else:
753
922
  for task in tasks:
754
923
  __run_command(task,sem,timeout,quiet,dry_run,identity=None)
@@ -777,13 +946,12 @@ def join_threads(threads=...,timeout=None):
777
946
  @returns:
778
947
  None
779
948
  '''
780
- global __running_threads
781
949
  if threads is ...:
782
- threads = __running_threads
950
+ with __running_threads_lock:
951
+ threads = list(__running_threads)
783
952
  for thread in threads:
784
953
  thread.join(timeout=timeout)
785
- if threads is __running_threads:
786
- __running_threads = {t for t in threads if t.is_alive()}
954
+ __reap_finished_threads()
787
955
 
788
956
  def main():
789
957
  parser = argparse.ArgumentParser(description='Run multiple commands in parallel')
@@ -0,0 +1,165 @@
1
+ import os
2
+ import threading
3
+ import time
4
+
5
+ import pytest
6
+
7
+ import multiCMD
8
+ from multiCMD import AsyncExecutor, run_commands
9
+
10
+
11
+ @pytest.mark.slow
12
+ def test_executor_run_wait_results_codes():
13
+ ex = AsyncExecutor(max_threads=2, timeout=0, quiet=True)
14
+ ex.run_command(["echo", "a"])
15
+ ex.run_commands([["echo", "b"], ["false"]])
16
+ assert len(ex) == 3
17
+ assert bool(ex)
18
+ ex.wait(timeout=2)
19
+ assert ex.get_return_codes() == [0, 0, 1]
20
+ assert ex.get_results() == [["a"], ["b"], []]
21
+
22
+
23
+ @pytest.mark.slow
24
+ def test_executor_join_prints_errors(capsys):
25
+ ex = AsyncExecutor(max_threads=1, quiet=True)
26
+ ex.run_command(["false"])
27
+ ex.join(timeout=2, print_error=True)
28
+ captured = capsys.readouterr()
29
+ assert "failed with return code" in captured.out
30
+
31
+
32
+ @pytest.mark.slow
33
+ def test_executor_stop_and_cleanup():
34
+ ex = AsyncExecutor(max_threads=1, quiet=True)
35
+ ex.run_command(["sleep", "5"])
36
+ time.sleep(0.1)
37
+ ex.stop(timeout=3)
38
+ # stop signals task.stop; process should end
39
+ assert all(not t.is_alive() for t in ex.tasks)
40
+ ex.cleanup(timeout=1)
41
+ assert list(ex) == []
42
+
43
+
44
+ @pytest.mark.slow
45
+ def test_join_threads_module_helper():
46
+ tasks = run_commands(
47
+ [["sleep", "0.3"]],
48
+ wait_for_return=False,
49
+ return_object=True,
50
+ quiet=True,
51
+ )
52
+ assert tasks[0].returncode is None
53
+ multiCMD.join_threads(timeout=2)
54
+ assert tasks[0].returncode == 0
55
+
56
+
57
+ def _global_threads():
58
+ return getattr(multiCMD, '__running_threads')
59
+
60
+
61
+ def _stack_mappings(size):
62
+ count = 0
63
+ with open('/proc/self/maps') as maps:
64
+ for line in maps:
65
+ fields = line.split()
66
+ if len(fields) == 5 and fields[1] == 'rw-p':
67
+ low, high = (int(x, 16) for x in fields[0].split('-'))
68
+ count += high - low == size
69
+ return count
70
+
71
+
72
+ def test_executor_queues_commands_instead_of_parking_threads():
73
+ gate = threading.Semaphore(0)
74
+ baseline = threading.active_count()
75
+ ex = AsyncExecutor(semaphore=gate, quiet=True, dry_run=True)
76
+ start = time.monotonic()
77
+ tasks = [ex.run_command(['true']) for _ in range(5000)]
78
+ assert time.monotonic() - start < 5
79
+ # no permit is free, so only the dispatcher exists and it waits for one
80
+ assert threading.active_count() <= baseline + 1
81
+ assert all(task.thread is None for task in tasks)
82
+ for _ in range(len(tasks)):
83
+ gate.release()
84
+ ex.wait(timeout=-1)
85
+ assert ex.runningThreads == []
86
+ assert all(task.thread is not None and not task.is_alive() for task in tasks)
87
+
88
+
89
+ @pytest.mark.slow
90
+ def test_executor_thread_count_stays_bounded():
91
+ baseline = threading.active_count()
92
+ ex = AsyncExecutor(semaphore=threading.Semaphore(3), quiet=True)
93
+ ex.run_commands([['sleep', '0.05']] * 60)
94
+ peak = 0
95
+ while ex.runningThreads:
96
+ peak = max(peak, threading.active_count())
97
+ time.sleep(0.005)
98
+ # the dispatcher, plus a runner and two stream readers per running command
99
+ assert peak - baseline <= 1 + 3 * 3
100
+ assert ex.get_return_codes() == [0] * 60
101
+
102
+
103
+ @pytest.mark.slow
104
+ @pytest.mark.skipif(not os.path.exists('/proc/self/maps'), reason='needs /proc/self/maps')
105
+ def test_executor_releases_finished_thread_stacks():
106
+ # a stack size nothing else uses, so these threads' stacks can be counted in /proc/self/maps
107
+ stack = 8 * 1024 * 1024 + 3 * 4096
108
+ old_stack = threading.stack_size(stack)
109
+ try:
110
+ ex = AsyncExecutor(semaphore=threading.Semaphore(6), quiet=True)
111
+ for _ in range(1200):
112
+ ex.run_command(['sleep', '0.01'])
113
+ ex.wait(timeout=-1)
114
+ # the executor and its tasks are still referenced: every finished thread must already be joined
115
+ assert ex.get_return_codes() == [0] * 1200
116
+ # glibc keeps a few freed stacks cached for reuse
117
+ assert _stack_mappings(stack) <= 8
118
+ finally:
119
+ threading.stack_size(old_stack)
120
+
121
+
122
+ def test_executor_threads_stay_out_of_global_set():
123
+ ex = AsyncExecutor(max_threads=2, quiet=True)
124
+ tasks = ex.run_commands([['true'], ['true']])
125
+ ex.wait(timeout=-1)
126
+ assert not {task.thread for task in tasks} & _global_threads()
127
+
128
+
129
+ def test_finished_fire_and_forget_threads_leave_global_set():
130
+ tasks = run_commands([['true']] * 20, wait_for_return=False, return_object=True, quiet=True)
131
+ for task in tasks:
132
+ task.thread.join()
133
+ run_commands([['true']], wait_for_return=False, quiet=True)
134
+ assert not {task.thread for task in tasks} & _global_threads()
135
+
136
+
137
+ def test_stop_skips_queued_commands():
138
+ ex = AsyncExecutor(semaphore=threading.Semaphore(0), quiet=True)
139
+ tasks = ex.run_commands([['sleep', '5']] * 3)
140
+ start = time.monotonic()
141
+ ex.stop(timeout=2)
142
+ assert time.monotonic() - start < 2
143
+ assert ex.runningThreads == []
144
+ assert all(task.thread is None and task.returncode is None for task in tasks)
145
+
146
+
147
+ @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
148
+ def test_failed_thread_start_keeps_executor_usable(monkeypatch):
149
+ ex = AsyncExecutor(max_threads=2, quiet=True)
150
+ real_start = threading.Thread.start
151
+
152
+ def fail_start(self):
153
+ if getattr(self, '_target', None) is multiCMD._run_executor_task:
154
+ raise RuntimeError("can't start new thread")
155
+ real_start(self)
156
+ monkeypatch.setattr(threading.Thread, 'start', fail_start)
157
+ failed = ex.run_command(['true'])
158
+ ex.wait(timeout=-1)
159
+ assert ex.runningThreads == []
160
+ assert failed.thread is None
161
+ monkeypatch.setattr(threading.Thread, 'start', real_start)
162
+ # the permit taken for the failed start was given back, and a new dispatcher picks up new work
163
+ tasks = ex.run_commands([['true'], ['true']])
164
+ ex.wait(timeout=-1)
165
+ assert [task.returncode for task in tasks] == [0, 0]
@@ -1,52 +0,0 @@
1
- import time
2
-
3
- import pytest
4
-
5
- import multiCMD
6
- from multiCMD import AsyncExecutor, run_commands
7
-
8
-
9
- @pytest.mark.slow
10
- def test_executor_run_wait_results_codes():
11
- ex = AsyncExecutor(max_threads=2, timeout=0, quiet=True)
12
- ex.run_command(["echo", "a"])
13
- ex.run_commands([["echo", "b"], ["false"]])
14
- assert len(ex) == 3
15
- assert bool(ex)
16
- ex.wait(timeout=2)
17
- assert ex.get_return_codes() == [0, 0, 1]
18
- assert ex.get_results() == [["a"], ["b"], []]
19
-
20
-
21
- @pytest.mark.slow
22
- def test_executor_join_prints_errors(capsys):
23
- ex = AsyncExecutor(max_threads=1, quiet=True)
24
- ex.run_command(["false"])
25
- ex.join(timeout=2, print_error=True)
26
- captured = capsys.readouterr()
27
- assert "failed with return code" in captured.out
28
-
29
-
30
- @pytest.mark.slow
31
- def test_executor_stop_and_cleanup():
32
- ex = AsyncExecutor(max_threads=1, quiet=True)
33
- ex.run_command(["sleep", "5"])
34
- time.sleep(0.1)
35
- ex.stop(timeout=3)
36
- # stop signals task.stop; process should end
37
- assert all(not t.is_alive() for t in ex.tasks)
38
- ex.cleanup(timeout=1)
39
- assert list(ex) == []
40
-
41
-
42
- @pytest.mark.slow
43
- def test_join_threads_module_helper():
44
- tasks = run_commands(
45
- [["sleep", "0.3"]],
46
- wait_for_return=False,
47
- return_object=True,
48
- quiet=True,
49
- )
50
- assert tasks[0].returncode is None
51
- multiCMD.join_threads(timeout=2)
52
- assert tasks[0].returncode == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes