-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathlog_monitor.py
More file actions
404 lines (346 loc) · 16.9 KB
/
log_monitor.py
File metadata and controls
404 lines (346 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# Author: Ozy
"""
Log file monitor for Star Citizen Game.log.
Aggressive polling-based monitoring for real-time performance.
Watchdog is unreliable on Windows for rapidly-changing files - pure polling is faster and more reliable.
"""
import time
import os
import sys
from pathlib import Path
from typing import Callable, Optional
from datetime import datetime
import threading
import traceback
# Global debug flag (set by main app)
_DEBUG_ENABLED = False
def set_debug_logging(enabled: bool):
"""Enable or disable debug logging at runtime."""
global _DEBUG_ENABLED
_DEBUG_ENABLED = enabled
# Debug logging helper
def _debug_log(msg: str):
"""Write debug messages to starlogs_debug.log for troubleshooting (only if debug enabled)."""
if not _DEBUG_ENABLED:
return
try:
debug_log = Path(__file__).parent / "starlogs_debug.log"
with open(debug_log, 'a', encoding='utf-8') as f:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
f.write(f"[{timestamp}] [LogMonitor] {msg}\n")
f.flush()
except Exception as e:
# Fail silently - don't break monitoring if debug logging fails
pass
class LogFilePoller:
"""Aggressively polls log file for changes (more reliable than watchdog on Windows)."""
def __init__(self, log_path: str, callback: Callable[[str], None], parent_monitor):
"""
Initialize poller.
Args:
log_path: Path to the log file to monitor
callback: Function to call with new log lines
parent_monitor: Reference to parent LogMonitor for diagnostics
"""
self.log_path = Path(log_path)
self.callback = callback
self.parent_monitor = parent_monitor
self.last_position = 0
self._lock = threading.Lock()
# Get initial file size
if self.log_path.exists():
self.last_position = self.log_path.stat().st_size
def check_for_changes(self):
"""Check for file changes and read new lines."""
with self._lock:
try:
_debug_log(f"Polling check started for {self.log_path}")
if not self.log_path.exists():
_debug_log(f"Log file does not exist: {self.log_path}")
return
current_size = self.log_path.stat().st_size
_debug_log(f"File size: {current_size}, last position: {self.last_position}")
# Handle log rotation (file got smaller)
if current_size < self.last_position:
_debug_log(f"Log rotation detected - resetting position")
self.last_position = 0
if current_size > self.last_position:
bytes_read = current_size - self.last_position
_debug_log(f"New data available: {bytes_read} bytes")
# Use Windows file sharing flags to prevent locking game's log writes
# This prevents game stuttering/hanging when we read the log
if sys.platform == 'win32':
try:
# Try using win32file for proper sharing control
import win32file
import pywintypes
_debug_log("Opening file with win32file (FILE_SHARE_WRITE enabled)")
# Open with FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE
# This allows the game to write while we read (critical!)
handle = win32file.CreateFile(
str(self.log_path),
win32file.GENERIC_READ,
win32file.FILE_SHARE_READ | win32file.FILE_SHARE_WRITE | win32file.FILE_SHARE_DELETE,
None,
win32file.OPEN_EXISTING,
0,
None
)
# Convert handle to file descriptor
import msvcrt
fd = msvcrt.open_osfhandle(handle.Detach(), os.O_RDONLY)
f = os.fdopen(fd, 'r', encoding='utf-8', errors='ignore', buffering=8192)
_debug_log("File opened successfully with win32file")
except ImportError as ie:
# Fallback if pywin32 not available - os.open should work but less explicit
_debug_log(f"win32file not available ({ie}), using os.open fallback")
import msvcrt
fd = os.open(str(self.log_path), os.O_RDONLY | os.O_BINARY, 0)
f = os.fdopen(fd, 'r', encoding='utf-8', errors='ignore', buffering=8192)
_debug_log("File opened with os.open fallback")
except Exception as win_err:
_debug_log(f"ERROR opening with win32file: {win_err}")
_debug_log(f"Traceback: {traceback.format_exc()}")
raise
else:
# Non-Windows fallback (Linux/Mac don't have same locking issues)
_debug_log("Opening file with standard open() (non-Windows)")
f = open(self.log_path, 'r', encoding='utf-8', errors='ignore', buffering=8192)
try:
f.seek(self.last_position)
new_lines = f.read()
self.last_position = f.tell()
_debug_log(f"Read {len(new_lines)} characters from file")
# Process each line
line_count = 0
for line in new_lines.splitlines():
if line.strip():
self.callback(line)
line_count += 1
_debug_log(f"Processed {line_count} lines")
# Update parent monitor stats
if line_count > 0:
self.parent_monitor.lines_read += line_count
self.parent_monitor.bytes_read += bytes_read
self.parent_monitor.check_count += 1
finally:
f.close()
_debug_log("File closed successfully")
else:
_debug_log("No new data - skipping read")
except Exception as e:
error_msg = f"ERROR in check_for_changes: {type(e).__name__}: {e}"
error_trace = traceback.format_exc()
_debug_log(error_msg)
_debug_log(f"Full traceback:\n{error_trace}")
print(f"[ERROR] Reading log file: {e}")
print(f"[ERROR] See starlogs_debug.log for full details")
traceback.print_exc()
class LogMonitor:
"""Monitors a Star Citizen log file with aggressive polling (no watchdog)."""
def __init__(self, log_path: str, line_callback: Callable[[str], None], poll_interval: float = 1.0):
"""
Initialize log monitor.
Args:
log_path: Path to the Game.log file
line_callback: Function to call with each new log line
poll_interval: Time in seconds between polling checks (default: 1.0)
"""
self.log_path = Path(log_path)
self.line_callback = line_callback
self.poll_interval = poll_interval
self.poller = None
self._running = False
self._polling_thread = None
# Diagnostics
self.check_count = 0
self.lines_read = 0
self.bytes_read = 0
self.start_time = None
_debug_log(f"LogMonitor initialized with poll_interval={poll_interval}s")
def replay_entire_log(self) -> int:
"""
Read and replay the ENTIRE log file from the beginning.
This gathers all data since game boot.
Returns:
Number of lines processed
"""
if not self.log_path.exists():
return 0
line_count = 0
try:
# Use Windows file sharing to prevent blocking game
if sys.platform == 'win32':
try:
import win32file
import msvcrt
# Open with FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE
handle = win32file.CreateFile(
str(self.log_path),
win32file.GENERIC_READ,
win32file.FILE_SHARE_READ | win32file.FILE_SHARE_WRITE | win32file.FILE_SHARE_DELETE,
None,
win32file.OPEN_EXISTING,
0,
None
)
fd = msvcrt.open_osfhandle(handle.Detach(), os.O_RDONLY)
f = os.fdopen(fd, 'r', encoding='utf-8', errors='ignore', buffering=65536)
except ImportError:
import msvcrt
fd = os.open(str(self.log_path), os.O_RDONLY | os.O_BINARY, 0)
f = os.fdopen(fd, 'r', encoding='utf-8', errors='ignore', buffering=65536)
else:
f = open(self.log_path, 'r', encoding='utf-8', errors='ignore', buffering=65536)
try:
for line in f:
if line.strip():
try:
self.line_callback(line.rstrip('\n'))
line_count += 1
except Exception as callback_error:
print(f"[ERROR] Failed to process log line: {callback_error}")
# Continue processing other lines
finally:
f.close()
print(f"[INFO] Replay complete: processed {line_count} lines")
except Exception as e:
print(f"[ERROR] Error replaying log file: {e}")
import traceback
traceback.print_exc()
return line_count
def tail_existing_content(self, num_lines: int = 100) -> None:
"""
Read the last N lines from the existing log file.
Args:
num_lines: Number of lines to read from the end
"""
if not self.log_path.exists():
return
try:
# Use Windows file sharing to prevent blocking game
if sys.platform == 'win32':
try:
import win32file
import msvcrt
# Open with FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE
handle = win32file.CreateFile(
str(self.log_path),
win32file.GENERIC_READ,
win32file.FILE_SHARE_READ | win32file.FILE_SHARE_WRITE | win32file.FILE_SHARE_DELETE,
None,
win32file.OPEN_EXISTING,
0,
None
)
fd = msvcrt.open_osfhandle(handle.Detach(), os.O_RDONLY)
f = os.fdopen(fd, 'r', encoding='utf-8', errors='ignore', buffering=65536)
except ImportError:
import msvcrt
fd = os.open(str(self.log_path), os.O_RDONLY | os.O_BINARY, 0)
f = os.fdopen(fd, 'r', encoding='utf-8', errors='ignore', buffering=65536)
else:
f = open(self.log_path, 'r', encoding='utf-8', errors='ignore', buffering=65536)
try:
# Read all lines and get the last N
lines = f.readlines()
tail_lines = lines[-num_lines:] if len(lines) > num_lines else lines
for line in tail_lines:
if line.strip():
self.line_callback(line.rstrip('\n'))
finally:
f.close()
except Exception as e:
print(f"Error tailing log file: {e}")
def start(self, tail_lines: int = 100, replay_all: bool = True) -> bool:
"""
Start monitoring the log file with aggressive polling.
Args:
tail_lines: Number of existing lines to read on start (if replay_all=False)
replay_all: If True, replay entire log from beginning (default)
Returns:
True if started successfully, False otherwise
"""
if self._running:
return True
if not self.log_path.exists():
print(f"[ERROR] Log file not found: {self.log_path}")
return False
self.start_time = time.time()
# Read existing content - either entire file or just tail
if replay_all:
print(f"[INFO] Replaying entire log file from game boot...")
line_count = self.replay_entire_log()
print(f"[INFO] Processed {line_count} existing log lines")
# Signal end of replay
self.line_callback("__REPLAY_COMPLETE__")
elif tail_lines > 0:
self.tail_existing_content(tail_lines)
self.line_callback("__REPLAY_COMPLETE__")
# Set up poller
self.poller = LogFilePoller(str(self.log_path), self.line_callback, self)
self._running = True
# Start polling thread
self._polling_thread = threading.Thread(target=self._polling_worker, daemon=True)
self._polling_thread.start()
print(f"[OK] Log monitor started (polling mode)")
print(f"[OK] Monitoring: {self.log_path}")
print(f"[OK] Poll interval: {self.poll_interval}s")
_debug_log(f"Log monitor started with {self.poll_interval}s poll interval")
return True
def _polling_worker(self):
"""Background polling thread that checks for file changes at configured interval."""
_debug_log(f"Polling thread started (checking every {self.poll_interval}s)")
print(f"[INFO] Polling thread started (checking every {self.poll_interval}s)")
while self._running:
try:
_debug_log(f"--- Poll cycle starting (interval={self.poll_interval}s) ---")
if self.poller:
self.poller.check_for_changes()
else:
_debug_log("WARNING: Poller is None!")
_debug_log(f"--- Poll cycle complete, sleeping {self.poll_interval}s ---")
time.sleep(self.poll_interval)
except Exception as e:
error_msg = f"ERROR in polling worker: {type(e).__name__}: {e}"
error_trace = traceback.format_exc()
_debug_log(error_msg)
_debug_log(f"Full traceback:\n{error_trace}")
print(f"[ERROR] Polling worker: {e}")
print(f"[ERROR] See starlogs_debug.log for full details")
time.sleep(self.poll_interval) # Back off on error
_debug_log("Polling thread exiting")
def stop(self) -> None:
"""Stop monitoring the log file."""
if self._running:
self._running = False
if self._polling_thread:
self._polling_thread.join(timeout=1.0)
def is_running(self) -> bool:
"""Check if monitor is currently running."""
return self._running
def get_diagnostics(self) -> dict:
"""
Get monitoring diagnostics for troubleshooting.
Returns:
Dict with monitoring stats
"""
runtime = time.time() - self.start_time if self.start_time else 0
return {
'running': self._running,
'log_path': str(self.log_path),
'log_exists': self.log_path.exists(),
'runtime_seconds': round(runtime, 1),
'poll_checks': self.check_count,
'checks_per_second': round(self.check_count / runtime, 2) if runtime > 0 else 0,
'lines_read': self.lines_read,
'bytes_read': self.bytes_read,
'current_position': self.poller.last_position if self.poller else 0,
'file_size': self.log_path.stat().st_size if self.log_path.exists() else 0
}
def trigger_reprocess(self) -> int:
"""Trigger a reprocess of the entire log file."""
line_count = self.replay_entire_log()
# Send separator signal after reprocess
self.line_callback("__REPLAY_COMPLETE__")
return line_count