From adc851569f8b94bb6478ea42d5fb53af69c0675a Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:56:11 +0200 Subject: [PATCH 1/3] feat: forward errors to stderr --- backend/pkg/logger/trace/trace.go | 32 +++++++++++++++++++++++++-- electron-app/src/processes/backend.js | 30 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/backend/pkg/logger/trace/trace.go b/backend/pkg/logger/trace/trace.go index d6b44b876..0c36f21c6 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 warn/error/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.WarnLevel, + } + // 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/src/processes/backend.js b/electron-app/src/processes/backend.js index 5ec35879e..8be49dc35 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -28,9 +28,30 @@ 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; +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.", + }, +]; + +function getHint(errorText) { + const match = ERROR_HINTS.find(({ pattern }) => pattern.test(errorText)); + return match + ? `${match.message}\n\n${match.advice}\n\n${errorText}` + : errorText; +} + /** * Starts the backend process by spawning the backend binary with the user configuration. * @returns {void} @@ -73,6 +94,9 @@ async function startBackend(logWindow = null) { cwd: workingDir, }); + console.log("[DEBUG] backendProcess.stderr:", backendProcess.stderr); + console.log("[DEBUG] backendProcess.stdout:", backendProcess.stdout); + // Log stdout output from backend backendProcess.stdout.on("data", (data) => { const text = data.toString().trim(); @@ -101,6 +125,7 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); + console.log("[DEBUG stderr chunk]", JSON.stringify(errorMsg)); lastBackendError = errorMsg; // Send error message to log window @@ -129,7 +154,8 @@ 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, ""); + errorMessage += `\n\n${getHint(stripped)}`; } else { errorMessage += "\n\n(No error output captured)"; } From 3bbba264083e37c8e0d8e20c2ad51846e443530e Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:34:52 +0200 Subject: [PATCH 2/3] refact: add backendError.js with hints --- backend/pkg/logger/trace/trace.go | 4 +- electron-app/README.md | 2 +- electron-app/src/processes/backend.js | 29 +------ electron-app/src/processes/backendError.js | 93 ++++++++++++++++++++++ 4 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 electron-app/src/processes/backendError.js diff --git a/backend/pkg/logger/trace/trace.go b/backend/pkg/logger/trace/trace.go index 0c36f21c6..21ce9d18b 100644 --- a/backend/pkg/logger/trace/trace.go +++ b/backend/pkg/logger/trace/trace.go @@ -69,11 +69,11 @@ 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 warn/error/fatal logs to stderr so the + // 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.WarnLevel, + minLevel: zerolog.FatalLevel, } // Try to create/open the file for writing logs. On failure, fall back to console only and exit. 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 8be49dc35..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(); @@ -31,27 +32,6 @@ let storedLogWindow = null; // Store error messages accumulated from the current process run let lastBackendError = null; -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.", - }, -]; - -function getHint(errorText) { - const match = ERROR_HINTS.find(({ pattern }) => pattern.test(errorText)); - return match - ? `${match.message}\n\n${match.advice}\n\n${errorText}` - : errorText; -} - /** * Starts the backend process by spawning the backend binary with the user configuration. * @returns {void} @@ -94,9 +74,6 @@ async function startBackend(logWindow = null) { cwd: workingDir, }); - console.log("[DEBUG] backendProcess.stderr:", backendProcess.stderr); - console.log("[DEBUG] backendProcess.stdout:", backendProcess.stdout); - // Log stdout output from backend backendProcess.stdout.on("data", (data) => { const text = data.toString().trim(); @@ -125,7 +102,6 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); - console.log("[DEBUG stderr chunk]", JSON.stringify(errorMsg)); lastBackendError = errorMsg; // Send error message to log window @@ -155,7 +131,8 @@ async function startBackend(logWindow = null) { if (lastBackendError) { const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); - errorMessage += `\n\n${getHint(stripped)}`; + 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..a75a49fe4 --- /dev/null +++ b/electron-app/src/processes/backendError.js @@ -0,0 +1,93 @@ +/** + * @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.", + }, +]; + +/** + * 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 }; From 010e99b8871d92871621f47caa5affeca04b9c72 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:55:44 +0200 Subject: [PATCH 3/3] feat: add more error hints --- electron-app/src/processes/backendError.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/electron-app/src/processes/backendError.js b/electron-app/src/processes/backendError.js index a75a49fe4..01df228ac 100644 --- a/electron-app/src/processes/backendError.js +++ b/electron-app/src/processes/backendError.js @@ -39,6 +39,24 @@ const ERROR_HINTS = [ 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.", + }, ]; /**