multiCMD 1.49__tar.gz → 1.51__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.51
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -206,6 +206,14 @@ 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 command's `task.thread` is `None` until it starts, and goes
213
+ back to `None` once it has finished and the executor has joined its thread, so a
214
+ large batch does not keep a `Thread` object per finished command. `stop()` skips
215
+ commands that have not started yet.
216
+
209
217
  ### Range expansion from Python
210
218
 
211
219
  ```python
@@ -180,6 +180,14 @@ 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 command's `task.thread` is `None` until it starts, and goes
187
+ back to `None` once it has finished and the executor has joined its thread, so a
188
+ large batch does not keep a `Thread` object per finished command. `stop()` skips
189
+ commands that have not started yet.
190
+
183
191
  ### Range expansion from Python
184
192
 
185
193
  ```python
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.49
3
+ Version: 1.51
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -206,6 +206,14 @@ 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 command's `task.thread` is `None` until it starts, and goes
213
+ back to `None` once it has finished and the executor has joined its thread, so a
214
+ large batch does not keep a `Thread` object per finished command. `stop()` skips
215
+ commands that have not started yet.
216
+
209
217
  ### Range expansion from Python
210
218
 
211
219
  ```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.51'
26
27
  __version__ = version
27
- COMMIT_DATE = '2026-08-10'
28
+ COMMIT_DATE = '2026-10-10'
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,31 @@ 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 command's task.thread is None until it
100
+ starts, and goes back to None once it has finished and the executor has joined its thread,
101
+ so finished tasks do not keep their Thread objects.
76
102
  @params:
77
103
  max_threads: The maximum number of threads to use ( int ) ( Note: if passing semaphore, this likely will be ignored )
78
104
  semaphore: The semaphore to use for threading ( threading.Semaphore )
@@ -85,13 +111,19 @@ class AsyncExecutor:
85
111
  if semaphore is ...:
86
112
  semaphore = threading.Semaphore(max_threads)
87
113
  self.semaphore = semaphore
88
- self.runningThreads = []
89
114
  self.tasks = []
90
115
  self.timeout = timeout
91
116
  self.quiet = quiet
92
117
  self.dry_run = dry_run
93
118
  self.parse = parse
94
- self.__lastNotJoined = 0
119
+ self._lock = threading.Lock()
120
+ # (task, sem, timeout, quiet, dry_run) waiting for a permit
121
+ self._queue = collections.deque()
122
+ self._dispatching = False
123
+ # started threads not yet joined, in start order ( dict used as an ordered set )
124
+ self._threads = {}
125
+ # threads append themselves here when done, so _reap() joins them without scanning
126
+ self._finished = collections.deque()
95
127
 
96
128
  def __iter__(self):
97
129
  return iter(self.tasks)
@@ -107,10 +139,90 @@ class AsyncExecutor:
107
139
 
108
140
  def __bool__(self):
109
141
  return bool(self.tasks)
110
-
142
+
143
+ @property
144
+ def runningThreads(self):
145
+ '''
146
+ The threads that have not finished yet: the dispatcher while commands are queued, and the started commands
147
+ @returns:
148
+ list[threading.Thread]: The unfinished threads
149
+ '''
150
+ self._reap()
151
+ with self._lock:
152
+ return list(self._threads)
153
+
154
+ def _reap(self):
155
+ '''
156
+ Join the threads that finished and forget them ( internal )
157
+ Joining releases the thread's stack: a finished thread that is still referenced but never
158
+ joined keeps its stack mapped.
159
+ '''
160
+ while True:
161
+ try:
162
+ thread, task = self._finished.popleft()
163
+ except IndexError:
164
+ return
165
+ thread.join()
166
+ with self._lock:
167
+ self._threads.pop(thread, None)
168
+ # a finished Thread object costs ~2 KB, and the task outlives it in self.tasks
169
+ if task is not None and task.thread is thread:
170
+ task.thread = None
171
+
172
+ def _start_thread(self, target, args, task=None):
173
+ thread = threading.Thread(target=target, args=args, daemon=True)
174
+ # registered before it starts, so it is tracked before it can report itself finished
175
+ with self._lock:
176
+ self._threads[thread] = None
177
+ if task is not None:
178
+ # set before it starts, so _reap() never sees the task without its thread
179
+ task.thread = thread
180
+ try:
181
+ thread.start()
182
+ except BaseException:
183
+ # never started: it would never report itself finished
184
+ del self._threads[thread]
185
+ if task is not None:
186
+ task.thread = None
187
+ raise
188
+ return thread
189
+
190
+ def _dispatch(self):
191
+ '''
192
+ Start queued commands one permit at a time ( internal )
193
+ '''
194
+ try:
195
+ while True:
196
+ with self._lock:
197
+ if not self._queue:
198
+ self._dispatching = False
199
+ break
200
+ task, sem, timeout, quiet, dry_run = self._queue.popleft()
201
+ # wait for a permit, but give up on a command that gets stopped meanwhile
202
+ acquired = False
203
+ while not task.stop and not acquired:
204
+ acquired = sem.acquire(timeout=0.1)
205
+ if task.stop:
206
+ if acquired:
207
+ sem.release()
208
+ continue
209
+ self._reap()
210
+ try:
211
+ self._start_thread(_run_executor_task, (task, _HeldPermit(sem), timeout, quiet, dry_run, self._finished), task)
212
+ except BaseException:
213
+ sem.release()
214
+ raise
215
+ except BaseException:
216
+ # let the next run_commands() start a new dispatcher for what is still queued
217
+ with self._lock:
218
+ self._dispatching = False
219
+ raise
220
+ finally:
221
+ self._finished.append((threading.current_thread(), None))
222
+
111
223
  def run_commands(self, commands, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
112
224
  '''
113
- Run multiple commands in parallel asynchronously
225
+ Run multiple commands in parallel asynchronously. Returns right away, the commands are queued
114
226
  @params:
115
227
  commands: A list of commands to run ( list[str] | list[list[str]] )
116
228
  timeout: The timeout for each command to override the object default
@@ -134,19 +246,26 @@ class AsyncExecutor:
134
246
  parse = self.parse
135
247
  if sem is ...:
136
248
  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])
249
+ taskObjects, use_sudo = _build_tasks(commands, parse=parse)
250
+ if not sem:
251
+ sem = threading.Semaphore(max_threads if max_threads >= 1 else max(len(taskObjects), 1))
252
+ if use_sudo:
253
+ _validate_sudo(sem)
254
+ self._reap()
255
+ with self._lock:
256
+ self.tasks.extend(taskObjects)
257
+ self._queue.extend((task, sem, timeout, quiet, dry_run) for task in taskObjects)
258
+ start_dispatcher = not self._dispatching and bool(self._queue)
259
+ if start_dispatcher:
260
+ # only the dispatcher clears this, under the lock, once it sees the queue empty
261
+ self._dispatching = True
262
+ if start_dispatcher:
263
+ try:
264
+ self._start_thread(self._dispatch, ())
265
+ except BaseException:
266
+ with self._lock:
267
+ self._dispatching = False
268
+ raise
150
269
  return taskObjects
151
270
 
152
271
  def run_command(self, command, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
@@ -169,26 +288,30 @@ class AsyncExecutor:
169
288
  '''
170
289
  Wait for the threads to finish
171
290
  @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
291
+ timeout: The timeout for each thread to override the object default ( negative waits forever )
292
+ threads: The threads to join, default to all running threads managed by this object,
293
+ including the ones the dispatcher starts while waiting
174
294
  @returns:
175
295
  list[threading.Thread]: The list of running threads that are still running
176
296
  '''
177
- if threads is ...:
178
- threads = self.runningThreads
179
297
  if timeout is ...:
180
298
  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()]
299
+ while True:
300
+ pending = self.runningThreads if threads is ... else list(threads)
301
+ for thread in pending:
302
+ if timeout >= 0:
303
+ thread.join(timeout=timeout)
304
+ else:
305
+ thread.join()
306
+ # once a pass finishes every thread, look again for commands the dispatcher started meanwhile
307
+ if threads is not ... or not pending or any(thread.is_alive() for thread in pending):
308
+ break
187
309
  return self.runningThreads
188
310
 
189
311
  def stop(self,timeout=...):
190
312
  '''
191
- Stop all running threads. This signals all threads to stop and joins them
313
+ Stop all running threads. This signals all threads to stop and joins them.
314
+ Commands still queued are skipped: they keep task.thread None and returncode None
192
315
  @params:
193
316
  None
194
317
  @returns:
@@ -208,8 +331,10 @@ class AsyncExecutor:
208
331
  list[Task]: The list of tasks that are managed by this object
209
332
  '''
210
333
  self.stop(timeout)
211
- self.tasks = []
212
- self.runningThreads = []
334
+ with self._lock:
335
+ self.tasks = []
336
+ self._queue.clear()
337
+ self._threads.clear()
213
338
  return self.tasks
214
339
 
215
340
  def join(self, timeout=..., threads = ..., print_error=True):
@@ -578,6 +703,69 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
578
703
  else:
579
704
  return task.stdout
580
705
 
706
+ def _run_executor_task(task, permit, timeout, quiet, dry_run, finished):
707
+ '''
708
+ Run an AsyncExecutor command, then report this thread finished so the executor joins it and drops it from the task ( internal )
709
+ '''
710
+ try:
711
+ __run_command(task, permit, timeout, quiet, dry_run, identity=...)
712
+ finally:
713
+ finished.append((threading.current_thread(), task))
714
+
715
+ def __run_tracked_command(task, sem, timeout, quiet, dry_run, identity):
716
+ '''
717
+ Run a fire-and-forget command, then report this thread finished so it leaves __running_threads ( internal )
718
+ '''
719
+ try:
720
+ __run_command(task, sem, timeout, quiet, dry_run, identity=identity)
721
+ finally:
722
+ __finished_threads.append(threading.current_thread())
723
+
724
+ def __reap_finished_threads():
725
+ '''
726
+ Join finished fire-and-forget threads and drop them from __running_threads ( internal )
727
+ Joining releases the thread's stack: a finished thread that is still referenced but never
728
+ joined keeps its stack mapped.
729
+ '''
730
+ while True:
731
+ try:
732
+ thread = __finished_threads.popleft()
733
+ except IndexError:
734
+ return
735
+ thread.join()
736
+ with __running_threads_lock:
737
+ __running_threads.discard(thread)
738
+
739
+ def _build_tasks(commands, parse=False, use_sudo=...):
740
+ '''
741
+ Format commands into Tasks, prefixing sudo when requested and available ( internal )
742
+
743
+ @returns:
744
+ tuple[list[Task], bool]: The tasks and whether sudo is used
745
+ '''
746
+ global USE_SUDO
747
+ global SUDO_PATH
748
+ if use_sudo is ...:
749
+ use_sudo = USE_SUDO
750
+ # Guard against requesting sudo when it is not available. Without this,
751
+ # prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
752
+ if use_sudo and not SUDO_PATH:
753
+ print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
754
+ use_sudo = False
755
+ # split the commands in commands if it is a string
756
+ formatedCommands = []
757
+ for command in commands:
758
+ formatedCommands.extend(__format_command(command,expand=parse))
759
+ if use_sudo:
760
+ formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
761
+ return [Task(command) for command in formatedCommands], bool(use_sudo)
762
+
763
+ def _validate_sudo(sem):
764
+ '''
765
+ Validate sudo access before running sudo commands ( internal )
766
+ '''
767
+ __run_command(Task([SUDO_PATH, '-v']),sem,timeout=60,quiet=True,dry_run=False,with_stdErr=False,identity=None)
768
+
581
769
  def __format_command(command,expand = False):
582
770
  '''
583
771
  Format a command
@@ -714,41 +902,31 @@ def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,wit
714
902
  @returns:
715
903
  list: The output of the commands ( list[None] | list[int] | list[list[str]] | list[Task] | None )
716
904
  '''
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
905
  # initialize the tasks
731
- if use_sudo:
732
- formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
733
- tasks = [Task(command) for command in formatedCommands]
906
+ tasks, use_sudo = _build_tasks(commands, parse=parse, use_sudo=use_sudo)
734
907
  # run the tasks with max_threads. if max_threads is 0, use the number of commands
735
908
  if max_threads < 1:
736
- max_threads = len(formatedCommands)
909
+ max_threads = len(tasks)
737
910
  if not sem:
738
911
  sem = threading.Semaphore(max_threads) # Limit concurrent sessions
739
912
  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:
913
+ _validate_sudo(sem)
914
+ if not wait_for_return:
915
+ __reap_finished_threads()
916
+ threads = [threading.Thread(target=__run_tracked_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
917
+ # tracked before they start, so a thread is always registered before it reports itself finished
918
+ with __running_threads_lock:
919
+ __running_threads.update(threads)
920
+ for thread,task in zip(threads,tasks):
921
+ task.thread = thread
922
+ thread.start()
923
+ elif max_threads > 1:
743
924
  threads = [threading.Thread(target=__run_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
744
925
  for thread,task in zip(threads,tasks):
745
926
  task.thread = thread
746
927
  thread.start()
747
- if wait_for_return:
748
- for thread in threads:
749
- thread.join()
750
- else:
751
- __running_threads.update(threads)
928
+ for thread in threads:
929
+ thread.join()
752
930
  else:
753
931
  for task in tasks:
754
932
  __run_command(task,sem,timeout,quiet,dry_run,identity=None)
@@ -777,13 +955,12 @@ def join_threads(threads=...,timeout=None):
777
955
  @returns:
778
956
  None
779
957
  '''
780
- global __running_threads
781
958
  if threads is ...:
782
- threads = __running_threads
959
+ with __running_threads_lock:
960
+ threads = list(__running_threads)
783
961
  for thread in threads:
784
962
  thread.join(timeout=timeout)
785
- if threads is __running_threads:
786
- __running_threads = {t for t in threads if t.is_alive()}
963
+ __reap_finished_threads()
787
964
 
788
965
  def main():
789
966
  parser = argparse.ArgumentParser(description='Run multiple commands in parallel')
@@ -0,0 +1,184 @@
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(monkeypatch):
73
+ ran = []
74
+ run_task = multiCMD._run_executor_task
75
+ monkeypatch.setattr(multiCMD, '_run_executor_task', lambda task, *args: (ran.append(task), run_task(task, *args)))
76
+ gate = threading.Semaphore(0)
77
+ baseline = threading.active_count()
78
+ ex = AsyncExecutor(semaphore=gate, quiet=True, dry_run=True)
79
+ start = time.monotonic()
80
+ tasks = [ex.run_command(['true']) for _ in range(5000)]
81
+ assert time.monotonic() - start < 5
82
+ # no permit is free, so only the dispatcher exists and it waits for one
83
+ assert threading.active_count() <= baseline + 1
84
+ assert all(task.thread is None for task in tasks)
85
+ for _ in range(len(tasks)):
86
+ gate.release()
87
+ ex.wait(timeout=-1)
88
+ assert ex.runningThreads == []
89
+ assert sorted(map(id, ran)) == sorted(map(id, tasks))
90
+ assert all(task.thread is None and not task.is_alive() for task in tasks)
91
+
92
+
93
+ @pytest.mark.slow
94
+ def test_executor_thread_count_stays_bounded():
95
+ baseline = threading.active_count()
96
+ ex = AsyncExecutor(semaphore=threading.Semaphore(3), quiet=True)
97
+ ex.run_commands([['sleep', '0.05']] * 60)
98
+ peak = 0
99
+ while ex.runningThreads:
100
+ peak = max(peak, threading.active_count())
101
+ time.sleep(0.005)
102
+ # the dispatcher, plus a runner and two stream readers per running command
103
+ assert peak - baseline <= 1 + 3 * 3
104
+ assert ex.get_return_codes() == [0] * 60
105
+
106
+
107
+ @pytest.mark.slow
108
+ @pytest.mark.skipif(not os.path.exists('/proc/self/maps'), reason='needs /proc/self/maps')
109
+ def test_executor_releases_finished_thread_stacks():
110
+ # a stack size nothing else uses, so these threads' stacks can be counted in /proc/self/maps
111
+ stack = 8 * 1024 * 1024 + 3 * 4096
112
+ old_stack = threading.stack_size(stack)
113
+ try:
114
+ ex = AsyncExecutor(semaphore=threading.Semaphore(6), quiet=True)
115
+ for _ in range(1200):
116
+ ex.run_command(['sleep', '0.01'])
117
+ ex.wait(timeout=-1)
118
+ # the executor and its tasks are still referenced: every finished thread must already be joined
119
+ assert ex.get_return_codes() == [0] * 1200
120
+ # glibc keeps a few freed stacks cached for reuse
121
+ assert _stack_mappings(stack) <= 8
122
+ finally:
123
+ threading.stack_size(old_stack)
124
+
125
+
126
+ def test_executor_threads_stay_out_of_global_set():
127
+ ex = AsyncExecutor(max_threads=2, quiet=True)
128
+ tasks = ex.run_commands([['sleep', '0.3'], ['sleep', '0.3']])
129
+ # finished tasks drop their thread, so take the threads while the commands run
130
+ deadline = time.monotonic() + 5
131
+ while not all(task.thread for task in tasks) and time.monotonic() < deadline:
132
+ time.sleep(0.01)
133
+ threads = {task.thread for task in tasks}
134
+ assert None not in threads
135
+ assert not threads & _global_threads()
136
+ ex.wait(timeout=-1)
137
+
138
+
139
+ def test_finished_fire_and_forget_threads_leave_global_set():
140
+ tasks = run_commands([['true']] * 20, wait_for_return=False, return_object=True, quiet=True)
141
+ for task in tasks:
142
+ task.thread.join()
143
+ run_commands([['true']], wait_for_return=False, quiet=True)
144
+ assert not {task.thread for task in tasks} & _global_threads()
145
+
146
+
147
+ def test_finished_executor_tasks_release_their_threads():
148
+ ex = AsyncExecutor(max_threads=4, quiet=True)
149
+ tasks = ex.run_commands([['true']] * 20)
150
+ ex.wait(timeout=-1)
151
+ assert [task.returncode for task in tasks] == [0] * 20
152
+ # a finished Thread object costs ~2 KB, kept for as long as the executor keeps the task
153
+ assert all(task.thread is None and not task.is_alive() for task in tasks)
154
+
155
+
156
+ def test_stop_skips_queued_commands():
157
+ ex = AsyncExecutor(semaphore=threading.Semaphore(0), quiet=True)
158
+ tasks = ex.run_commands([['sleep', '5']] * 3)
159
+ start = time.monotonic()
160
+ ex.stop(timeout=2)
161
+ assert time.monotonic() - start < 2
162
+ assert ex.runningThreads == []
163
+ assert all(task.thread is None and task.returncode is None for task in tasks)
164
+
165
+
166
+ @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
167
+ def test_failed_thread_start_keeps_executor_usable(monkeypatch):
168
+ ex = AsyncExecutor(max_threads=2, quiet=True)
169
+ real_start = threading.Thread.start
170
+
171
+ def fail_start(self):
172
+ if getattr(self, '_target', None) is multiCMD._run_executor_task:
173
+ raise RuntimeError("can't start new thread")
174
+ real_start(self)
175
+ monkeypatch.setattr(threading.Thread, 'start', fail_start)
176
+ failed = ex.run_command(['true'])
177
+ ex.wait(timeout=-1)
178
+ assert ex.runningThreads == []
179
+ assert failed.thread is None
180
+ monkeypatch.setattr(threading.Thread, 'start', real_start)
181
+ # the permit taken for the failed start was given back, and a new dispatcher picks up new work
182
+ tasks = ex.run_commands([['true'], ['true']])
183
+ ex.wait(timeout=-1)
184
+ 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