Skip to content

Commit f31d888

Browse files
committed
multiple: fix initial ui paint for i18n, improve proc handling, improve logging setup, update git.ignore
1 parent f0cefb4 commit f31d888

File tree

3 files changed

+218
-76
lines changed

3 files changed

+218
-76
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,7 @@ cython_debug/
206206
marimo/_static/
207207
marimo/_lsp/
208208
__marimo__/
209+
210+
# Junk
211+
.bak
212+
.old

workbench_core.py

Lines changed: 149 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,28 @@
2424
import shutil
2525
import os
2626
import random
27+
import signal
2728
import sys
2829
import subprocess
2930
import threading
31+
import time
3032
import http.cookiejar as _cj
3133

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+
3249
# -----------------------
3350
# Import/install helpers
3451
# -----------------------
@@ -74,57 +91,156 @@ def ensure_mutagen_installed(log) -> bool:
7491
CANCEL_EVENT = threading.Event()
7592

7693

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)
80104

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)
81112

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:
84142
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
88155

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
89166

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:
94169
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)
96175
except Exception:
97176
pass
98177

178+
# Cleanup registry
179+
for p in procs:
180+
_unregister_proc(p)
99181

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
103188

104189

105190
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
110192
) -> 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)
121198
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
125217
finally:
126218
_unregister_proc(p)
127219

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+
128244

129245
def run_capture(
130246
cmd: list[str],
@@ -139,27 +255,6 @@ def run_capture(
139255
return out
140256

141257

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-
163258
# -----------------------
164259
# Tool resolution
165260
# -----------------------

0 commit comments

Comments
 (0)