diff --git a/backend/pkg/logger/trace/trace.go b/backend/pkg/logger/trace/trace.go index d6b44b876..21ce9d18b 100644 --- a/backend/pkg/logger/trace/trace.go +++ b/backend/pkg/logger/trace/trace.go @@ -69,6 +69,13 @@ func InitTrace(traceLevel string) *os.File { // Human-friendly console writer that prints logs to stdout. consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout} + // Console writer that prints fatal logs to stderr so the + // parent process (Electron) can capture them via the stderr pipe. + stderrConsoleWriter := &levelFilterWriter{ + w: zerolog.ConsoleWriter{Out: os.Stderr}, + minLevel: zerolog.FatalLevel, + } + // Try to create/open the file for writing logs. On failure, fall back to console only and exit. file, err := loggerbase.CreateFile(traceDir, Trace, traceFile) if err != nil { @@ -78,8 +85,8 @@ func InitTrace(traceLevel string) *os.File { return nil } - // Write logs to both the console and the file. - multi := zerolog.MultiLevelWriter(consoleWriter, file) + // Write logs to stdout, stderr (warn+), and the file. + multi := zerolog.MultiLevelWriter(consoleWriter, stderrConsoleWriter, file) // Create a new logger that includes timestamps and caller information. trace.Logger = zerolog.New(multi).With().Timestamp().Caller().Logger() @@ -96,3 +103,24 @@ func InitTrace(traceLevel string) *os.File { return file } + +// levelFilterWriter is a zerolog.LevelWriter that forwards log entries to w +// only when their level is >= minLevel. This lets us route warn/error/fatal +// to stderr while keeping info/debug on stdout. +type levelFilterWriter struct { + w zerolog.ConsoleWriter + minLevel zerolog.Level +} + +// Write satisfies the io.Writer interface required by zerolog.LevelWriter. +// MultiLevelWriter always calls WriteLevel instead. +func (f *levelFilterWriter) Write(p []byte) (int, error) { + return f.w.Write(p) +} + +func (f *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) { + if l >= f.minLevel { + return f.w.Write(p) + } + return len(p), nil +} diff --git a/electron-app/README.md b/electron-app/README.md index 7034c163e..d4e30bf0d 100644 --- a/electron-app/README.md +++ b/electron-app/README.md @@ -32,7 +32,7 @@ When running in development mode (unpackaged), the application creates temporary - **Configuration and Logs**: Stored in `{UserConfigDir}/hyperloop-control-station/` (using Go's `os.UserConfigDir()`) - Config files and backups: `{UserConfigDir}/hyperloop-control-station/configs/` - - Trace/log files: `{UserConfigDir}/hyperloop-control-station/trace-*.json` + - Trace/log files: `{UserConfigDir}/hyperloop-control-station/configs/trace-*.json` - **ADJ Module**: Stored in `{UserCacheDir}/hyperloop-control-station/adj/` (using Go's `os.UserCacheDir()`) diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 5ec35879e..b021edfdc 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -15,6 +15,7 @@ import { getBinaryPath, getUserConfigPath, } from "../utils/paths.js"; +import { formatBackendError, getHint } from "./backendError.js"; // Create ANSI to HTML converter const convert = new AnsiToHtml(); @@ -28,7 +29,7 @@ let backendProcess = null; // Common log window instance for all backend processes let storedLogWindow = null; -// Store error messages (keep last 10 lines to avoid memory issues) +// Store error messages accumulated from the current process run let lastBackendError = null; /** @@ -129,7 +130,9 @@ async function startBackend(logWindow = null) { let errorMessage = `Backend exited with code ${code}`; if (lastBackendError) { - errorMessage += `\n\n${lastBackendError}`; + const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); + const formatted = formatBackendError(stripped); + errorMessage += `\n\n${getHint(stripped, formatted)}`; } else { errorMessage += "\n\n(No error output captured)"; } diff --git a/electron-app/src/processes/backendError.js b/electron-app/src/processes/backendError.js new file mode 100644 index 000000000..01df228ac --- /dev/null +++ b/electron-app/src/processes/backendError.js @@ -0,0 +1,111 @@ +/** + * @module processes + * @description Error formatting and hint utilities for backend crash diagnostics. + * Parses zerolog console output, strips ANSI codes, and maps known error patterns + * to actionable user-facing messages shown in the crash dialog. + */ + +/** + * List of known error patterns with human-readable messages and fix advice. + * Each entry is matched against the raw stripped stderr output. + */ +const ERROR_HINTS = [ + { + pattern: /bind: The requested address is not valid/, + message: "Network address unavailable", + advice: + "The configured IP address doesn't exist on this machine. Check your network adapter or ADJ.", + }, + { + pattern: /failed to start UDP server/, + message: "UDP server failed to start", + advice: "Another process may already be using this port.", + }, + { + pattern: /jsonschema/, + message: "ADJ Validator dependency missing", + advice: + "Install the required Python package by running: pip install jsonschema==4.25.0", + }, + { + pattern: /No Python interpreter found/, + message: "Python not found", + advice: + "Install Python 3 and make sure it is accessible via 'python3', 'python', or 'py' in your PATH.", + }, + { + pattern: /ADJ Validator failed with error/, + message: "ADJ validation failed", + advice: + "Your ADJ files contain schema errors. Check the ADJ validator log file in the logs folder for details.", + }, + { + pattern: /error reading config file/, + message: "Config file not found", + advice: + "The configuration file could not be read. Check that the config file path is correct and the file exists.", + }, + { + pattern: /error unmarshaling toml file/, + message: "Config file has errors", + advice: + "The configuration file contains invalid TOML. Check the config file for syntax or type errors.", + }, + { + pattern: /setting up ADJ/, + message: "ADJ not available", + advice: + "Could not load the ADJ. If this is your first run, connect to the internet so the ADJ can be downloaded.", + }, +]; + +/** + * Reformats a single stripped zerolog console line into a readable block. + * Zerolog console format: "TIME LEVEL FILE > message key=value ..." + * @param {string} line - A single log line with ANSI codes already stripped. + * @returns {string} A formatted multi-line string with level, file, and key-value pairs on separate lines. + * @example + * formatLine("11:43AM FTL setup_transport.go:143 > failed to start UDP server error=\"some error\""); + * // "[FTL] at setup_transport.go:143\n failed to start UDP server\n error: \"some error\"" + */ +function formatLine(line) { + const m = line.match(/^\S+\s+(\S+)\s+(\S+)\s+>\s+(.*)/); + if (!m) return line; + const [, level, file, rest] = m; + const body = rest.replace( + /\s+(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g, + "\n $1: $2", + ); + return `[${level}] at ${file}\n ${body.trim()}`; +} + +/** + * Formats a full multi-line stderr output by reformatting each zerolog line. + * @param {string} text - Raw stderr text with ANSI codes already stripped. + * @returns {string} Formatted text with each log line reformatted for readability. + * @example + * formatBackendError("11:43AM FTL file.go:10 > something failed error=\"bad\""); + */ +function formatBackendError(text) { + return text.split("\n").filter(Boolean).map(formatLine).join("\n\n"); +} + +/** + * Returns a user-facing error message by matching the raw error against known patterns. + * If a match is found, prepends a hint and advice to the formatted error. + * Falls back to the formatted error text if no pattern matches. + * @param {string} raw - Raw stripped stderr text used for pattern matching. + * @param {string} formatted - Pre-formatted version of the error for display. + * @returns {string} The final message to show in the crash dialog. + * @example + * getHint("failed to start UDP server ...", "[FTL] at ..."); + * // "UDP server failed to start\n\nAnother process may already be using this port.\n\n[FTL] at ..." + */ +function getHint(raw, formatted) { + const match = ERROR_HINTS.find(({ pattern }) => pattern.test(raw)); + return match + ? `${match.message}\n\n${match.advice}\n\n${formatted}` + : formatted; +} + +export { formatBackendError, getHint };