24
24
import shutil
25
25
import os
26
26
import random
27
+ import signal
27
28
import sys
28
29
import subprocess
29
30
import threading
31
+ import time
30
32
import http .cookiejar as _cj
31
33
34
+ # Global registry of running child processes
35
+ _CURRENT_PROCS_LOCK = threading .RLock ()
36
+ _CURRENT_PROCS : set [subprocess .Popen ] = set ()
37
+
38
+
39
+ def _register_proc (p : subprocess .Popen ) -> None :
40
+ with _CURRENT_PROCS_LOCK :
41
+ _CURRENT_PROCS .add (p )
42
+
43
+
44
+ def _unregister_proc (p : subprocess .Popen ) -> None :
45
+ with _CURRENT_PROCS_LOCK :
46
+ _CURRENT_PROCS .discard (p )
47
+
48
+
32
49
# -----------------------
33
50
# Import/install helpers
34
51
# -----------------------
@@ -74,57 +91,156 @@ def ensure_mutagen_installed(log) -> bool:
74
91
CANCEL_EVENT = threading .Event ()
75
92
76
93
77
- def _register_proc (p : subprocess .Popen ) -> None :
78
- with CURRENT_PROCS_LOCK :
79
- CURRENT_PROCS .append (p )
94
+ def _spawn_process (cmd : list [str ], ** kwargs ) -> subprocess .Popen :
95
+ """
96
+ Start a child in its own process group to support cross-platform cancellation.
97
+ Always pass an argv list (no shell).
98
+ """
99
+ # Text mode defaults
100
+ kwargs .setdefault ("stdin" , subprocess .PIPE )
101
+ kwargs .setdefault ("stdout" , subprocess .PIPE )
102
+ kwargs .setdefault ("stderr" , subprocess .PIPE )
103
+ kwargs .setdefault ("text" , True )
80
104
105
+ if os .name == "nt" :
106
+ # New process group so we can send CTRL_BREAK_EVENT
107
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
108
+ kwargs ["creationflags" ] = kwargs .get ("creationflags" , 0 ) | CREATE_NEW_PROCESS_GROUP
109
+ else :
110
+ # New group; SIGTERM/SIGKILL can be sent to the whole tree
111
+ kwargs .setdefault ("preexec_fn" , os .setsid )
81
112
82
- def _unregister_proc (p : subprocess .Popen ) -> None :
83
- with CURRENT_PROCS_LOCK :
113
+ p = subprocess .Popen (cmd , ** kwargs ) # nosec: trusted argv list
114
+ _register_proc (p )
115
+ return p
116
+
117
+
118
+ def spawn_streaming (cmd : list [str ], ** kwargs ) -> subprocess .Popen :
119
+ """
120
+ Start a long-running command whose stdout/stderr you will read incrementally.
121
+ Use this for yt-dlp streaming in the GUI.
122
+ """
123
+ p = _spawn_process (cmd , ** kwargs )
124
+ return p
125
+
126
+
127
+ def finalize_process (p : subprocess .Popen ) -> None :
128
+ """Remove a child from the registry after it exits (GUI streaming case)."""
129
+ _unregister_proc (p )
130
+
131
+
132
+ def terminate_all_procs (timeout : float = 3.0 ) -> None :
133
+ """
134
+ Try graceful shutdown of all registered children, then escalate.
135
+ Safe to call multiple times.
136
+ """
137
+ with _CURRENT_PROCS_LOCK :
138
+ procs = list (_CURRENT_PROCS )
139
+
140
+ # 1) Graceful
141
+ for p in procs :
84
142
try :
85
- CURRENT_PROCS .remove (p )
86
- except ValueError :
87
- pass
143
+ if p .poll () is not None :
144
+ continue
145
+ if os .name == "nt" :
146
+ p .send_signal (signal .CTRL_BREAK_EVENT )
147
+ else :
148
+ # signal whole group
149
+ os .killpg (os .getpgid (p .pid ), signal .SIGTERM )
150
+ except Exception :
151
+ try :
152
+ p .terminate ()
153
+ except Exception :
154
+ pass
88
155
156
+ # 2) Wait a bit
157
+ deadline = time .time () + max (0.1 , timeout )
158
+ for p in procs :
159
+ try :
160
+ if p .poll () is None :
161
+ remaining = deadline - time .time ()
162
+ if remaining > 0 :
163
+ p .wait (remaining )
164
+ except Exception :
165
+ pass
89
166
90
- def terminate_all_procs () -> None :
91
- with CURRENT_PROCS_LOCK :
92
- to_kill = list (CURRENT_PROCS )
93
- for p in to_kill :
167
+ # 3) Hard kill leftovers
168
+ for p in procs :
94
169
try :
95
- p .terminate ()
170
+ if p .poll () is None :
171
+ if os .name == "nt" :
172
+ p .kill ()
173
+ else :
174
+ os .killpg (os .getpgid (p .pid ), signal .SIGKILL )
96
175
except Exception :
97
176
pass
98
177
178
+ # Cleanup registry
179
+ for p in procs :
180
+ _unregister_proc (p )
99
181
100
- # -----------------------
101
- # Subprocess helpers
102
- # -----------------------
182
+
183
+ def _last_lines (s : str , n : int = 12 ) -> str :
184
+ try :
185
+ return "\n " .join (s .splitlines ()[- n :])
186
+ except Exception :
187
+ return s
103
188
104
189
105
190
def _run_capture (
106
- cmd : list [str ],
107
- cwd : Path | None = None ,
108
- env : dict [str , str ] | None = None ,
109
- text : bool = True ,
191
+ cmd : list [str ], * , timeout : float | None = None , check : bool = True , cwd : str | None = None
110
192
) -> tuple [int , str , str ]:
111
- """Run a command; return (rc, stdout, stderr). Tests monkeypatch this."""
112
- p = subprocess .Popen (
113
- cmd ,
114
- cwd = str (cwd ) if cwd else None ,
115
- env = env ,
116
- stdout = subprocess .PIPE ,
117
- stderr = subprocess .PIPE ,
118
- text = text ,
119
- )
120
- _register_proc (p )
193
+ """
194
+ Run a command to completion, capturing stdout/stderr with optional timeout.
195
+ Raises CalledProcessError when check=True and rc != 0 (with stderr tail).
196
+ """
197
+ p = _spawn_process (cmd , cwd = cwd )
121
198
try :
122
- out , err = p .communicate ()
123
- rc = p .returncode or 0
124
- return rc , out or "" , err or ""
199
+ out , err = p .communicate (timeout = timeout )
200
+ except subprocess .TimeoutExpired :
201
+ # escalate on timeout
202
+ try :
203
+ if os .name == "nt" :
204
+ p .send_signal (signal .CTRL_BREAK_EVENT )
205
+ else :
206
+ os .killpg (os .getpgid (p .pid ), signal .SIGTERM )
207
+ except Exception :
208
+ pass
209
+ finally :
210
+ try :
211
+ out , err = p .communicate (timeout = 1.0 )
212
+ except Exception :
213
+ out , err = "" , ""
214
+ rc = p .poll () if p .poll () is not None else - 9
215
+ else :
216
+ rc = p .returncode
125
217
finally :
126
218
_unregister_proc (p )
127
219
220
+ if check and rc != 0 :
221
+ tail = _last_lines (err )
222
+ raise subprocess .CalledProcessError (rc , cmd , output = out , stderr = tail )
223
+ return rc , out , err
224
+
225
+
226
+ def run_quiet (
227
+ cmd : list [str ], * , timeout : float | None = None , cwd : str | None = None
228
+ ) -> tuple [int , str ]:
229
+ """
230
+ Run and return (rc, stderr_tail). Keeps last lines of stderr for diagnostics.
231
+ """
232
+ try :
233
+ rc , _out , err = _run_capture (cmd , timeout = timeout , check = False , cwd = cwd )
234
+ except subprocess .CalledProcessError as e :
235
+ # shouldn't happen because check=False above, but just in case
236
+ return e .returncode , _last_lines (e .stderr or "" )
237
+ return rc , _last_lines (err or "" )
238
+
239
+
240
+ # -----------------------
241
+ # Subprocess helpers
242
+ # -----------------------
243
+
128
244
129
245
def run_capture (
130
246
cmd : list [str ],
@@ -139,27 +255,6 @@ def run_capture(
139
255
return out
140
256
141
257
142
- def run_quiet (
143
- cmd : list [str ],
144
- cwd : Path | None = None ,
145
- env : dict [str , str ] | None = None ,
146
- ) -> int :
147
- """Run without capturing output; return rc."""
148
- p = subprocess .Popen (
149
- cmd ,
150
- cwd = str (cwd ) if cwd else None ,
151
- env = env ,
152
- stdout = subprocess .DEVNULL ,
153
- stderr = subprocess .DEVNULL ,
154
- )
155
- _register_proc (p )
156
- try :
157
- p .wait ()
158
- return p .returncode or 0
159
- finally :
160
- _unregister_proc (p )
161
-
162
-
163
258
# -----------------------
164
259
# Tool resolution
165
260
# -----------------------
0 commit comments