Skip to content

Commit 0e60714

Browse files
committed
log buffer code optimization
1 parent 311234f commit 0e60714

File tree

9 files changed

+135
-150
lines changed

9 files changed

+135
-150
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ At its core, Pythonator is a **local Python process manager with a GUI**.
7171
## Capabilities
7272

7373
* ✅ Full ANSI color recognition (256-color, true color, bold, inverse)
74-
* ✅ Multi-bot process management with isolation
74+
* ✅ Multi-script process management with isolation
7575
* ✅ Separate execution paths for UI and processes (`QProcess`, `QThreadPool`)
7676
* ✅ Live / History / Search log modes
7777
* ✅ CPU and RAM usage monitoring (via `psutil`)

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass, asdict
55
from pathlib import Path
66

7-
__version__ = "1.0.1"
7+
__version__ = "1.0.2"
88

99
# Paths
1010
def _app_dir() -> Path:

log_buffer.py

Lines changed: 49 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,49 @@
1-
"""
2-
Log buffer - Ring buffer with timestamps and file persistence.
3-
4-
NOTE: Log file writes are performed asynchronously on a background thread.
5-
Qt delivers process output on the main (UI) thread; synchronous disk I/O here
6-
can cause UI stalls/freeze during heavy output or when stopping many processes.
7-
"""
1+
"""Log buffer - Ring buffer with async file persistence (non-blocking UI)."""
82
from __future__ import annotations
9-
3+
import atexit, queue, threading
104
from collections import deque
115
from datetime import datetime
126
from pathlib import Path
13-
import queue
14-
import threading
157
from typing import Optional
16-
178
from config import LOGS_DIR, MAX_LOG_LINES, HISTORY_CHUNK, normalize, strip_ansi
189

10+
class _AsyncWriter:
11+
"""Background thread for non-blocking log file writes."""
12+
_instance: Optional["_AsyncWriter"] = None
1913

20-
class _AsyncFileWriter:
21-
"""Non-blocking log file appender.
22-
23-
UI thread enqueues writes; a worker thread does disk I/O.
24-
"""
25-
26-
def __init__(self, max_queue: int = 10000):
27-
self._q: "queue.Queue[tuple[Path, str]]" = queue.Queue(maxsize=max_queue)
14+
def __init__(self):
15+
self._q: queue.Queue[tuple[Path, str]] = queue.Queue(maxsize=10000)
2816
self._stop = threading.Event()
29-
self._dropped = 0
30-
self._thread = threading.Thread(target=self._run, name="log-writer", daemon=True)
17+
self._thread = threading.Thread(target=self._run, daemon=True)
3118
self._thread.start()
19+
atexit.register(self.close)
20+
21+
@classmethod
22+
def get(cls) -> "_AsyncWriter":
23+
if cls._instance is None: cls._instance = cls()
24+
return cls._instance
3225

3326
def write(self, path: Path, text: str) -> None:
34-
"""Enqueue text to append to a file. Never blocks the caller."""
35-
if not text:
36-
return
37-
try:
38-
self._q.put_nowait((path, text))
39-
except queue.Full:
40-
# Never block the UI thread. Drop the chunk and count it.
41-
self._dropped += 1
27+
if text:
28+
try: self._q.put_nowait((path, text))
29+
except queue.Full: pass # Drop rather than block UI
4230

4331
def _run(self) -> None:
4432
while not self._stop.is_set() or not self._q.empty():
45-
try:
46-
path, text = self._q.get(timeout=0.2)
47-
except queue.Empty:
48-
continue
49-
33+
try: path, text = self._q.get(timeout=0.2)
34+
except queue.Empty: continue
5035
try:
5136
path.parent.mkdir(exist_ok=True)
52-
with open(path, "a", encoding="utf-8", newline="\n") as f:
53-
f.write(text)
54-
55-
# If we dropped anything, record it once we successfully write again.
56-
if self._dropped:
57-
dropped = self._dropped
58-
self._dropped = 0
59-
try:
60-
with open(path, "a", encoding="utf-8", newline="\n") as f:
61-
f.write(f"[log-writer] dropped {dropped} chunks due to backpressure\n")
62-
except Exception:
63-
pass
64-
except Exception:
65-
# Never crash the writer thread.
66-
pass
37+
with open(path, "a", encoding="utf-8", newline="\n") as f: f.write(text)
38+
except: pass
6739
finally:
68-
try:
69-
self._q.task_done()
70-
except Exception:
71-
pass
40+
try: self._q.task_done()
41+
except: pass
7242

73-
def close(self, timeout_s: float = 2.0) -> None:
74-
"""Request stop and wait briefly for draining."""
43+
def close(self) -> None:
7544
self._stop.set()
76-
try:
77-
self._thread.join(timeout=timeout_s)
78-
except Exception:
79-
pass
80-
81-
82-
_GLOBAL_WRITER: Optional[_AsyncFileWriter] = None
83-
84-
85-
def _writer() -> _AsyncFileWriter:
86-
global _GLOBAL_WRITER
87-
if _GLOBAL_WRITER is None:
88-
_GLOBAL_WRITER = _AsyncFileWriter()
89-
return _GLOBAL_WRITER
90-
91-
92-
def shutdown_log_writer() -> None:
93-
"""Flush pending async log writes and stop the writer thread."""
94-
global _GLOBAL_WRITER
95-
if _GLOBAL_WRITER is None:
96-
return
97-
_GLOBAL_WRITER.close()
98-
_GLOBAL_WRITER = None
99-
45+
try: self._thread.join(timeout=2.0)
46+
except: pass
10047

10148
class LogBuffer:
10249
__slots__ = ("name", "lines", "file", "_cache", "_mtime", "_partial")
@@ -111,92 +58,57 @@ def __init__(self, name: str):
11158
self._partial: str = ""
11259

11360
def append(self, text: str) -> tuple[str, str]:
114-
if not text:
115-
return "", ""
116-
117-
text = normalize(text)
118-
data = self._partial + text
119-
self._partial = ""
120-
121-
# No newline yet: keep buffering partial line
122-
if "\n" not in data:
123-
self._partial = data
124-
return "", ""
125-
61+
if not text: return "", ""
62+
data = normalize(self._partial + text); self._partial = ""
63+
if "\n" not in data: self._partial = data; return "", ""
64+
12665
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
12766
parts = data.splitlines(keepends=True)
128-
129-
# Keep trailing partial (no newline)
130-
if parts and not parts[-1].endswith("\n"):
131-
self._partial = parts.pop()
132-
67+
if parts and not parts[-1].endswith("\n"): self._partial = parts.pop()
68+
13369
display, file_out = [], []
13470
for part in parts:
13571
content = part.rstrip("\n")
13672
disp = f"[\x1b[94m{ts}\x1b[0m] {content}\n"
137-
self.lines.append(disp)
138-
display.append(disp)
73+
self.lines.append(disp); display.append(disp)
13974
file_out.append(f"[{ts}] {strip_ansi(content)}\n")
140-
75+
14176
self._cache = None
142-
143-
# Persist asynchronously to keep UI thread responsive.
144-
try:
145-
_writer().write(self.file, "".join(file_out))
146-
except Exception:
147-
pass
148-
77+
_AsyncWriter.get().write(self.file, "".join(file_out))
14978
return "".join(display), "".join(file_out)
15079

151-
def get_recent(self) -> str:
152-
return "".join(self.lines)
80+
def get_recent(self) -> str: return "".join(self.lines)
15381

15482
def _read_file(self) -> list[str]:
155-
if not self.file.exists():
156-
return [l.rstrip("\n") for l in self.lines]
83+
if not self.file.exists(): return [l.rstrip("\n") for l in self.lines]
15784
try:
15885
mtime = self.file.stat().st_mtime
159-
if self._cache and mtime == self._mtime:
160-
return self._cache
161-
self._cache = normalize(
162-
self.file.read_text(encoding="utf-8", errors="replace")
163-
).splitlines()
86+
if self._cache and mtime == self._mtime: return self._cache
87+
self._cache = normalize(self.file.read_text(encoding="utf-8", errors="replace")).splitlines()
16488
self._mtime = mtime
16589
return self._cache
166-
except Exception:
167-
return [l.rstrip("\n") for l in self.lines]
90+
except: return [l.rstrip("\n") for l in self.lines]
16891

169-
def line_count(self) -> int:
170-
return len(self._read_file())
92+
def line_count(self) -> int: return len(self._read_file())
17193

17294
def _colorize(self, line: str) -> str:
173-
if line.startswith("["):
174-
b = line.find("]")
175-
if b > 0:
176-
return f"[\x1b[94m{line[1:b]}\x1b[0m]{line[b+1:]}"
95+
if line.startswith("[") and (b := line.find("]")) > 0:
96+
return f"[\x1b[94m{line[1:b]}\x1b[0m]{line[b+1:]}"
17797
return line
17898

17999
def search(self, pattern: str) -> tuple[str, int]:
180100
p = pattern.lower()
181101
matches = [l for l in self._read_file() if p in l.lower()]
182-
if not matches:
183-
return "", 0
184-
return "".join(f"{self._colorize(l)}\n" for l in matches), len(matches)
102+
return ("".join(f"{self._colorize(l)}\n" for l in matches), len(matches)) if matches else ("", 0)
185103

186104
def load_chunk(self, end: int, size: int = HISTORY_CHUNK) -> tuple[str, int]:
187105
lines = self._read_file()
188-
if not lines or end <= 0:
189-
return "", 0
106+
if not lines or end <= 0: return "", 0
190107
start = max(0, end - size)
191108
chunk = lines[start:end]
192-
if not chunk:
193-
return "", 0
194-
return "".join(f"{self._colorize(l)}\n" for l in chunk), start
109+
return ("".join(f"{self._colorize(l)}\n" for l in chunk), start) if chunk else ("", 0)
195110

196111
def clear(self) -> None:
197-
self.lines.clear()
198-
self._cache = None
199-
try:
200-
self.file.write_text("", encoding="utf-8")
201-
except Exception:
202-
pass
112+
self.lines.clear(); self._cache = None
113+
try: self.file.write_text("", encoding="utf-8")
114+
except: pass

main_window.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from PyQt6.QtWidgets import (QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
99
QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget, QProxyStyle, QStyle)
1010
from config import Bot, load_config, save_config, FLUSH_INTERVAL_MS, STATS_INTERVAL_MS, BTN, INPUT, __version__
11-
from log_buffer import LogBuffer, shutdown_log_writer
11+
from log_buffer import LogBuffer
1212
from log_view import LogView
1313
from process_mgr import ProcessManager
1414
from stats import ProcessStats, StatsMonitor
@@ -65,7 +65,7 @@ def _build_config(self) -> QWidget:
6565
panel = QWidget(); layout = QVBoxLayout(panel); layout.setContentsMargins(0, 0, 8, 0); layout.setSpacing(8)
6666

6767
# Bot selector
68-
row = QHBoxLayout(); row.setSpacing(4)
68+
row = QHBoxLayout(); row.setSpacing(4); row.addWidget(QLabel("Bot:"))
6969
self.bot_combo = QComboBox()
7070
self.bot_combo.setStyleSheet("QComboBox { padding: 4px 8px; border: 1px solid #333; border-radius: 2px; background: #1a1a1a; } QComboBox:hover { border-color: #444; } QComboBox::drop-down { border: none; width: 20px; } QComboBox QAbstractItemView { background: #1a1a1a; border: 1px solid #333; selection-background-color: #4688d8; }")
7171
self.bot_combo.currentTextChanged.connect(self._on_combo_changed); row.addWidget(self.bot_combo, 1)
@@ -162,10 +162,10 @@ def _save_bot(self) -> None:
162162
save_config(self.bots)
163163

164164
def _add_bot(self) -> None:
165-
name, ok = QInputDialog.getText(self, "New Workspace", "Workspace name:")
165+
name, ok = QInputDialog.getText(self, "New Bot", "Bot name:")
166166
if not ok or not name.strip(): return
167167
name = name.strip()
168-
if name in self.bots: QMessageBox.warning(self, "Duplicate", f"Workspace '{name}' exists"); return
168+
if name in self.bots: QMessageBox.warning(self, "Duplicate", f"Bot '{name}' exists"); return
169169
self.bots[name] = Bot(name=name); save_config(self.bots)
170170
self._create_views(name); self.bot_combo.setCurrentText(name); self._load_bot_ui(name); self._update_ui()
171171

@@ -288,10 +288,6 @@ def _update_ui(self) -> None:
288288

289289
def closeEvent(self, event) -> None:
290290
self.proc_mgr.stop_all()
291-
for e in self._editors.values():
292-
e.close()
293-
if self._scratch:
294-
self._scratch.close()
295-
296-
shutdown_log_writer() # <-- important
297-
event.accept()
291+
for e in self._editors.values(): e.close()
292+
if self._scratch: self._scratch.close()
293+
event.accept() # atexit handles log writer cleanup

testscripts/crash/crashtest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import ctypes
2+
ctypes.cast(0xdeadbeef, ctypes.py_object).value

testscripts/flask_uvicorn/app.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import time
6+
from flask import Flask, jsonify, request
7+
8+
app = Flask(__name__)
9+
10+
@app.get("/")
11+
def home():
12+
return """
13+
<!doctype html>
14+
<html>
15+
<head><meta charset="utf-8"><title>Flask Test</title></head>
16+
<body style="font-family: monospace; background:#0a0a0a; color:#ddd; padding:16px">
17+
<h2>Flask Test App</h2>
18+
<p>Endpoints:</p>
19+
<ul>
20+
<li><a href="/health">/health</a></li>
21+
<li><a href="/spam?n=200">/spam?n=200</a> (prints a lot to stdout)</li>
22+
<li><a href="/slow?sec=2">/slow?sec=2</a> (delayed response)</li>
23+
<li><a href="/crash">/crash</a> (intentional crash)</li>
24+
</ul>
25+
<p>POST /echo (JSON) → echos body</p>
26+
</body>
27+
</html>
28+
""".strip()
29+
30+
@app.get("/health")
31+
def health():
32+
return jsonify(ok=True, pid=os.getpid(), python=sys.version)
33+
34+
@app.get("/slow")
35+
def slow():
36+
sec = float(request.args.get("sec", "1"))
37+
time.sleep(max(0.0, min(sec, 10.0)))
38+
print(f"[slow] slept {sec}s", flush=True)
39+
return jsonify(ok=True, slept=sec)
40+
41+
@app.get("/spam")
42+
def spam():
43+
n = int(request.args.get("n", "200"))
44+
n = max(0, min(n, 20000))
45+
for i in range(n):
46+
print(f"{i+1} hello", flush=True)
47+
return jsonify(ok=True, printed=n)
48+
49+
@app.post("/echo")
50+
def echo():
51+
data = request.get_json(silent=True)
52+
print(f"[echo] {data}", flush=True)
53+
return jsonify(ok=True, received=data)
54+
55+
@app.get("/crash")
56+
def crash():
57+
print("[crash] about to raise", flush=True)
58+
raise RuntimeError("Intentional crash for runner testing")
59+
60+
if __name__ == "__main__":
61+
# Use 127.0.0.1 so it's local only
62+
# threaded=True helps simulate concurrent requests
63+
app.run(host="127.0.0.1", port=5001, debug=False, threaded=True)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
flask
2+
uvicorn

testscripts/loop/loop.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import math
2+
3+
for i in range(100000):
4+
print(i, "hello")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
for i in range(100000):
2+
print(i, "hello")
3+
import time
4+
time.sleep(0.0001)
5+
6+
print("DONE")

0 commit comments

Comments
 (0)