403 lines
15 KiB
GDScript
403 lines
15 KiB
GDScript
@tool
|
||
extends RefCounted
|
||
## In-memory ring buffer capturing ALL console output.
|
||
##
|
||
## Two capture strategies, selected automatically at setup():
|
||
##
|
||
## Godot 4.5+ — Logger subclass (compiled at runtime via GDScript.new()
|
||
## to avoid parse errors on older versions). Captures
|
||
## print(), printerr(), push_warning(), push_error(),
|
||
## engine errors, shader errors. Zero latency.
|
||
##
|
||
## Godot 4.2–4.4 — Log file tailing. poll() reads new bytes from the
|
||
## configured log path since the last read offset.
|
||
## ~500ms polling interval, subject to Godot's
|
||
## file-flush timing.
|
||
##
|
||
## Thread safety: Logger callbacks can fire from any thread.
|
||
## All buffer access is Mutex-protected.
|
||
|
||
const LogHelpers := preload("res://addons/godot_mcp_toolkit/logging/log_helpers.gd")
|
||
# Export-clean (core APIs only) — safe in the runtime autoload's preload closure,
|
||
# which pulls this file in.
|
||
const VersionUtils := preload("res://addons/godot_mcp_toolkit/versioning/mcp_version_utils.gd")
|
||
|
||
const _CAPACITY := 500
|
||
const _POLL_INTERVAL_MS := 200
|
||
const _SCRIPT_ERROR_LATCH_CAPACITY := 16
|
||
|
||
# -- Shared state (Mutex-protected) ------------------------------------------
|
||
|
||
static var _mutex: Mutex = Mutex.new()
|
||
static var _entries: Array = []
|
||
static var _next_id: int = 0
|
||
# Structured {id, file, line} locations of captured SCRIPT errors (Logger
|
||
# error_type 2), latched beside their console entries. A separate side-channel
|
||
# on purpose: console entries keep their {id, timestamp_unix, level, message}
|
||
# wire shape, while script_check recovers the real parse line of a reload it
|
||
# just ran. Only the 4.5+ Logger fills it — the 4.2-4.4 file tail has no
|
||
# structured line data.
|
||
static var _script_error_locations: Array = []
|
||
|
||
# -- Strategy tracking --------------------------------------------------------
|
||
|
||
static var _use_logger: bool = false
|
||
static var _setup_done: bool = false
|
||
static var _logger_ref = null # prevent GC — OS.add_logger() holds a raw C++ pointer
|
||
|
||
# -- File-tail state (4.2-4.4 only) ------------------------------------------
|
||
|
||
static var _tail_offset: int = 0
|
||
static var _tail_path: String = ""
|
||
static var _last_poll_ms: int = 0
|
||
static var _tail_open_failures: int = 0
|
||
## Last detected level in the tail stream, so a continuation (" at: …") line inherits
|
||
## the preceding error/warning level instead of "info" (the location line carries the
|
||
## script path). Reset on (re)setup. See LogHelpers.is_continuation_line.
|
||
static var _last_tail_level: String = "info"
|
||
|
||
|
||
# =============================================================================
|
||
# Public API
|
||
# =============================================================================
|
||
|
||
|
||
## Returns true when the Logger API (4.5+) is active, false when using file
|
||
## tailing (4.2-4.4). Callers use this to decide whether source="buffer" is
|
||
## independent of file-logging settings.
|
||
static func uses_logger_api() -> bool:
|
||
return _use_logger
|
||
|
||
|
||
## Call once from plugin.gd _enter_tree().
|
||
static func setup() -> void:
|
||
if _setup_done:
|
||
return
|
||
_setup_done = true
|
||
|
||
if ClassDB.class_exists(&"Logger"):
|
||
_setup_logger()
|
||
else:
|
||
_setup_file_tail()
|
||
|
||
|
||
## Push an entry into the ring buffer. Thread-safe.
|
||
## Called by Logger callbacks (4.5+) or file tailer (4.2-4.4).
|
||
static func push(level: String, message: String) -> void:
|
||
_mutex.lock()
|
||
_append_entry_unlocked(level, message)
|
||
_mutex.unlock()
|
||
|
||
|
||
## Push a SCRIPT error (Logger error_type 2): the console entry plus its
|
||
## structured {id, file, line} location latch, atomically — the latch id is
|
||
## exactly the entry's id, so since-cursor correlation can never misattribute.
|
||
## Thread-safe. The console entry shape is identical to [method push].
|
||
static func push_script_error(level: String, message: String, file: String, line: int) -> void:
|
||
_mutex.lock()
|
||
var entry_id := _append_entry_unlocked(level, message)
|
||
_script_error_locations.append({"id": entry_id, "file": file, "line": line})
|
||
if _script_error_locations.size() > _SCRIPT_ERROR_LATCH_CAPACITY:
|
||
_script_error_locations.pop_front()
|
||
_mutex.unlock()
|
||
|
||
|
||
## Line of the first in-memory script error captured after [param since_id],
|
||
## or -1 when none was.
|
||
##
|
||
## Serves script_check's reload validation: a GDScript built from source (no
|
||
## disk path) reports under a synthetic "gdscript://…" path ("built-in" on
|
||
## engines predating that scheme), so matching that shape picks the caller's
|
||
## own just-triggered reload error and never a threaded editor-scan error on a
|
||
## res:// script that lands in the same window. Returns -1 on 4.2-4.4 (the
|
||
## file tail latches nothing) — callers omit their line field then.
|
||
static func find_script_error_line_since(since_id: int) -> int:
|
||
_mutex.lock()
|
||
var found := -1
|
||
for location in _script_error_locations:
|
||
if int(location["id"]) <= since_id:
|
||
continue
|
||
var file := str(location["file"])
|
||
if file == "built-in" or file.begins_with("gdscript://"):
|
||
found = int(location["line"])
|
||
break
|
||
_mutex.unlock()
|
||
return found
|
||
|
||
|
||
# Appends one entry and returns its id. Caller must hold _mutex.
|
||
static func _append_entry_unlocked(level: String, message: String) -> int:
|
||
var entry := {
|
||
"id": _next_id,
|
||
"timestamp_unix": int(Time.get_unix_time_from_system()),
|
||
"level": level,
|
||
"message": LogHelpers.strip_ansi(message),
|
||
}
|
||
_next_id += 1
|
||
if _entries.size() >= _CAPACITY:
|
||
_entries.pop_front()
|
||
_entries.append(entry)
|
||
return int(entry["id"])
|
||
|
||
|
||
## Read entries from the buffer. Calls poll() first to ensure freshness.
|
||
##
|
||
## Returns the capped tail of the filtered entries plus the pagination fields both
|
||
## log readers stamp onto the wire envelope: {entries, returned, next_id, has_more,
|
||
## total_lines}. [param since_id] is a resume cursor (entries with id <= it are
|
||
## dropped); the returned next_id is the last entry's id for the next call.
|
||
static func get_entries(limit: int, level_filter: Array = [], since_id: int = -1, text_filter: String = "", text_regex: RegEx = null) -> Dictionary:
|
||
poll()
|
||
|
||
_mutex.lock()
|
||
var filtered: Array = []
|
||
for entry in _entries:
|
||
if since_id >= 0 and int(entry["id"]) <= since_id:
|
||
continue
|
||
if level_filter.size() > 0 and not (str(entry["level"]) in level_filter):
|
||
continue
|
||
if text_filter != "":
|
||
var msg: String = str(entry["message"])
|
||
if text_regex != null:
|
||
if not text_regex.search(msg):
|
||
continue
|
||
else:
|
||
if msg.findn(text_filter) < 0:
|
||
continue
|
||
filtered.append(entry.duplicate())
|
||
_mutex.unlock()
|
||
|
||
# Capture the pre-slice filtered count for total_lines before the
|
||
# slice below reassigns `filtered` to the capped tail.
|
||
var total_lines := filtered.size()
|
||
var has_more := filtered.size() > limit
|
||
if has_more:
|
||
filtered = filtered.slice(filtered.size() - limit)
|
||
|
||
var next_id: int = -1
|
||
if filtered.size() > 0:
|
||
next_id = int(filtered[-1]["id"])
|
||
|
||
# Shared source for the log-paging envelope: both the editor console reader and
|
||
# the runtime debugger.get_log read these keys and stamp the final wire envelope,
|
||
# so the field names here follow the standard pagination vocabulary.
|
||
return {
|
||
"entries": filtered,
|
||
"returned": filtered.size(),
|
||
"next_id": next_id,
|
||
"has_more": has_more,
|
||
"total_lines": total_lines,
|
||
}
|
||
|
||
|
||
## Returns the ID that will be assigned to the next pushed entry.
|
||
## Snapshot this before game_start to filter entries by game session.
|
||
static func get_cursor() -> int:
|
||
_mutex.lock()
|
||
var cursor := _next_id
|
||
_mutex.unlock()
|
||
return cursor
|
||
|
||
|
||
## Reset the buffer (script-error location latch included — its ids reset too).
|
||
static func clear() -> void:
|
||
_mutex.lock()
|
||
_entries.clear()
|
||
_script_error_locations.clear()
|
||
_next_id = 0
|
||
_mutex.unlock()
|
||
|
||
|
||
## Remove entries matching a specific level (e.g. "error"). Returns count removed.
|
||
static func clear_level(level: String) -> int:
|
||
_mutex.lock()
|
||
var kept: Array = []
|
||
var removed := 0
|
||
for entry in _entries:
|
||
if str(entry["level"]) == level:
|
||
removed += 1
|
||
else:
|
||
kept.append(entry)
|
||
_entries = kept
|
||
_mutex.unlock()
|
||
return removed
|
||
|
||
|
||
## No-op on 4.5+ (Logger handles everything).
|
||
## On 4.2-4.4, tails the log file for new lines. Self-throttled.
|
||
## Safe to call every frame — returns immediately if interval not elapsed.
|
||
static func poll() -> void:
|
||
if _use_logger:
|
||
return
|
||
var now := Time.get_ticks_msec()
|
||
if now - _last_poll_ms < _POLL_INTERVAL_MS:
|
||
return
|
||
_last_poll_ms = now
|
||
_tail_log_file()
|
||
|
||
|
||
# =============================================================================
|
||
# Logger strategy (Godot 4.5+)
|
||
# =============================================================================
|
||
|
||
|
||
## Compose the console message for a structured engine error (the Logger `_log_error`
|
||
## callback). Mirrors the engine StdLogger's two-line
|
||
## "PREFIX: <detail>\n at: <function> (<file>:<line>)" shape.
|
||
##
|
||
## When [param append_location] is true the " at:" line is appended so the source path
|
||
## lives inside the entry. Editors up to 4.6 emit a separate path-bearing "Failed to load
|
||
## script" error during script scans, so the caller passes false there and the path is not
|
||
## duplicated; Godot 4.7 dropped that separate message from the scan path, leaving the path
|
||
## only in this callback's file arg, so the caller passes true to keep the filename
|
||
## captured (text/console filters match on it). A fileless / "built-in" source has no
|
||
## useful path, so the location is skipped. Pure — no editor symbols — so it is unit-tested
|
||
## headless.
|
||
static func compose_error_message(prefix: String, code: String, rationale: String,
|
||
function_name: String, file_path: String, line: int, append_location: bool) -> String:
|
||
var detail := code
|
||
if rationale != "":
|
||
detail = code + ": " + rationale
|
||
var full := prefix + ": " + detail
|
||
if append_location and file_path != "" and file_path != "built-in":
|
||
full += "\n at: " + function_name + " (" + file_path + ":" + str(line) + ")"
|
||
return full
|
||
|
||
|
||
static func _setup_logger() -> void:
|
||
_use_logger = true
|
||
# Compile the Logger subclass at runtime so no file on disk contains
|
||
# "extends Logger" — avoids parse errors on Godot 4.2-4.4.
|
||
var script := GDScript.new()
|
||
script.source_code = _LOGGER_SOURCE
|
||
var err := script.reload()
|
||
if err != OK:
|
||
push_warning("[LogBuffer] Logger hook compile failed (err %d), falling back to file tail" % err)
|
||
_use_logger = false
|
||
_setup_file_tail()
|
||
return
|
||
_logger_ref = script.new()
|
||
# Pass a reference to this script so the logger can call push() / compose_error_message().
|
||
_logger_ref.set_meta("_log_buffer", load("res://addons/godot_mcp_toolkit/logging/log_buffer.gd"))
|
||
# Godot 4.7 stopped surfacing the separate path-bearing "Failed to load script" error
|
||
# during editor script scans, leaving the script path only in the structured _log_error
|
||
# callback's file arg. Flag 4.7+ so the logger appends the engine-style " at:" location
|
||
# line to script errors and the filename stays captured; 4.5/4.6 keep prior byte-for-byte
|
||
# output (their separate load error already carries the path).
|
||
_logger_ref.set_meta("_append_error_location",
|
||
VersionUtils.is_at_least(VersionUtils.get_engine_version_pair(), "4.7"))
|
||
# Dynamic call — OS.add_logger() only exists in 4.5+; static reference
|
||
# causes a parse error on 4.2-4.4 even inside a guarded branch.
|
||
OS.call("add_logger", _logger_ref)
|
||
|
||
|
||
const _LOGGER_SOURCE := '
|
||
extends Logger
|
||
|
||
func _log_message(message: String, error: bool) -> void:
|
||
var buf = get_meta("_log_buffer")
|
||
if buf == null:
|
||
return
|
||
var level: String = "error" if error else "info"
|
||
buf.push(level, message.strip_edges())
|
||
|
||
func _log_error(function_name: String, file_path: String, line: int,
|
||
code: String, rationale: String, _editor_notify: bool,
|
||
error_type: int, _script_backtraces) -> void:
|
||
var buf = get_meta("_log_buffer")
|
||
if buf == null:
|
||
return
|
||
var level: String = "warning" if error_type == 1 else "error"
|
||
var prefix: String = "WARNING" if error_type == 1 else "ERROR"
|
||
# error_type 2 == Logger.ERR_SCRIPT: only script errors carry a .gd source path worth
|
||
# preserving. The 4.7+ flag (set at setup from the engine version) enables the location
|
||
# line; see compose_error_message.
|
||
var append_location: bool = bool(get_meta("_append_error_location", false)) and error_type == 2
|
||
var composed: String = buf.compose_error_message(prefix, code, rationale, function_name, file_path, line, append_location)
|
||
if error_type == 2:
|
||
# Script errors also latch their structured {file, line}: the composed
|
||
# message drops both for in-memory scripts, and script_check needs the
|
||
# real parse line of a reload it just ran.
|
||
buf.push_script_error(level, composed, file_path, line)
|
||
else:
|
||
buf.push(level, composed)
|
||
'
|
||
|
||
|
||
# =============================================================================
|
||
# File-tail strategy (Godot 4.2-4.4)
|
||
# =============================================================================
|
||
|
||
|
||
## Re-resolve the log tail path after a config/name change shifts user://.
|
||
## No-op when using Logger API (4.5+).
|
||
static func reset_tail_path() -> void:
|
||
if _use_logger:
|
||
return
|
||
_tail_path = LogHelpers.resolve_log_path()
|
||
# Don't reset offset — if the engine kept writing to the same file
|
||
# (and the absolute path didn't change), we keep our position.
|
||
# If it's a genuinely new file, _tail_log_file() handles the
|
||
# file_len < _tail_offset case by resetting automatically.
|
||
|
||
|
||
static func _setup_file_tail() -> void:
|
||
# Resolve to an absolute OS path so casual user:// resolution shifts
|
||
# (e.g. from config/name changes) don't silently break tailing.
|
||
# UserPathMonitor calls reset_tail_path() on rename to re-resolve.
|
||
_tail_path = LogHelpers.resolve_log_path()
|
||
# Start from beginning so the buffer captures the full current-session log.
|
||
# On Windows, Godot holds the log file open with buffered writes — new
|
||
# content written after plugin load may not be visible to our read handle
|
||
# until flushed. Starting from 0 ensures startup messages are captured.
|
||
_tail_offset = 0
|
||
_last_tail_level = "info"
|
||
|
||
|
||
static func _tail_log_file() -> void:
|
||
if _tail_path.is_empty():
|
||
return
|
||
if not FileAccess.file_exists(_tail_path):
|
||
return
|
||
var f := FileAccess.open(_tail_path, FileAccess.READ)
|
||
if f == null:
|
||
_tail_open_failures += 1
|
||
return
|
||
var file_len: int = f.get_length()
|
||
if file_len <= _tail_offset:
|
||
if file_len < _tail_offset:
|
||
# File was truncated/rotated — reset.
|
||
_tail_offset = 0
|
||
f.close()
|
||
return
|
||
f.seek(_tail_offset)
|
||
var remaining: int = file_len - _tail_offset
|
||
var new_bytes := f.get_buffer(remaining)
|
||
_tail_offset = file_len
|
||
f.close()
|
||
|
||
var new_text := new_bytes.get_string_from_utf8()
|
||
if new_text.is_empty():
|
||
return
|
||
var lines := new_text.split("\n")
|
||
for line in lines:
|
||
var stripped := line.strip_edges()
|
||
if stripped.is_empty():
|
||
continue
|
||
# A continuation (" at: …") line inherits the preceding error/warning level so
|
||
# a multi-line error stays error-leveled (its location line carries the script
|
||
# path) — matching the source=file reader's coalescing and the 4.5+ Logger's
|
||
# one-entry-per-error. See LogHelpers.is_continuation_line.
|
||
var level: String
|
||
if LogHelpers.is_continuation_line(line) \
|
||
and (_last_tail_level == "error" or _last_tail_level == "warning"):
|
||
level = _last_tail_level
|
||
else:
|
||
level = _detect_log_level(stripped)
|
||
push(level, stripped)
|
||
_last_tail_level = level
|
||
|
||
|
||
static func _detect_log_level(line: String) -> String:
|
||
return LogHelpers.detect_log_level(line)
|