Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions backend/pkg/logger/trace/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion electron-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`)

Expand Down
7 changes: 5 additions & 2 deletions electron-app/src/processes/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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)";
}
Expand Down
111 changes: 111 additions & 0 deletions electron-app/src/processes/backendError.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading