Skip to content

Commit 59abdd6

Browse files
Merge pull request #490 from Hyperloop-UPV/control-station/error-output-fix
fix: capture messages and show hints
2 parents 1be5e2e + 010e99b commit 59abdd6

File tree

4 files changed

+147
-5
lines changed

4 files changed

+147
-5
lines changed

backend/pkg/logger/trace/trace.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ func InitTrace(traceLevel string) *os.File {
6969
// Human-friendly console writer that prints logs to stdout.
7070
consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout}
7171

72+
// Console writer that prints fatal logs to stderr so the
73+
// parent process (Electron) can capture them via the stderr pipe.
74+
stderrConsoleWriter := &levelFilterWriter{
75+
w: zerolog.ConsoleWriter{Out: os.Stderr},
76+
minLevel: zerolog.FatalLevel,
77+
}
78+
7279
// Try to create/open the file for writing logs. On failure, fall back to console only and exit.
7380
file, err := loggerbase.CreateFile(traceDir, Trace, traceFile)
7481
if err != nil {
@@ -78,8 +85,8 @@ func InitTrace(traceLevel string) *os.File {
7885
return nil
7986
}
8087

81-
// Write logs to both the console and the file.
82-
multi := zerolog.MultiLevelWriter(consoleWriter, file)
88+
// Write logs to stdout, stderr (warn+), and the file.
89+
multi := zerolog.MultiLevelWriter(consoleWriter, stderrConsoleWriter, file)
8390

8491
// Create a new logger that includes timestamps and caller information.
8592
trace.Logger = zerolog.New(multi).With().Timestamp().Caller().Logger()
@@ -96,3 +103,24 @@ func InitTrace(traceLevel string) *os.File {
96103

97104
return file
98105
}
106+
107+
// levelFilterWriter is a zerolog.LevelWriter that forwards log entries to w
108+
// only when their level is >= minLevel. This lets us route warn/error/fatal
109+
// to stderr while keeping info/debug on stdout.
110+
type levelFilterWriter struct {
111+
w zerolog.ConsoleWriter
112+
minLevel zerolog.Level
113+
}
114+
115+
// Write satisfies the io.Writer interface required by zerolog.LevelWriter.
116+
// MultiLevelWriter always calls WriteLevel instead.
117+
func (f *levelFilterWriter) Write(p []byte) (int, error) {
118+
return f.w.Write(p)
119+
}
120+
121+
func (f *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
122+
if l >= f.minLevel {
123+
return f.w.Write(p)
124+
}
125+
return len(p), nil
126+
}

electron-app/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ When running in development mode (unpackaged), the application creates temporary
3232

3333
- **Configuration and Logs**: Stored in `{UserConfigDir}/hyperloop-control-station/` (using Go's `os.UserConfigDir()`)
3434
- Config files and backups: `{UserConfigDir}/hyperloop-control-station/configs/`
35-
- Trace/log files: `{UserConfigDir}/hyperloop-control-station/trace-*.json`
35+
- Trace/log files: `{UserConfigDir}/hyperloop-control-station/configs/trace-*.json`
3636

3737
- **ADJ Module**: Stored in `{UserCacheDir}/hyperloop-control-station/adj/` (using Go's `os.UserCacheDir()`)
3838

electron-app/src/processes/backend.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getBinaryPath,
1616
getUserConfigPath,
1717
} from "../utils/paths.js";
18+
import { formatBackendError, getHint } from "./backendError.js";
1819

1920
// Create ANSI to HTML converter
2021
const convert = new AnsiToHtml();
@@ -28,7 +29,7 @@ let backendProcess = null;
2829
// Common log window instance for all backend processes
2930
let storedLogWindow = null;
3031

31-
// Store error messages (keep last 10 lines to avoid memory issues)
32+
// Store error messages accumulated from the current process run
3233
let lastBackendError = null;
3334

3435
/**
@@ -129,7 +130,9 @@ async function startBackend(logWindow = null) {
129130
let errorMessage = `Backend exited with code ${code}`;
130131

131132
if (lastBackendError) {
132-
errorMessage += `\n\n${lastBackendError}`;
133+
const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, "");
134+
const formatted = formatBackendError(stripped);
135+
errorMessage += `\n\n${getHint(stripped, formatted)}`;
133136
} else {
134137
errorMessage += "\n\n(No error output captured)";
135138
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* @module processes
3+
* @description Error formatting and hint utilities for backend crash diagnostics.
4+
* Parses zerolog console output, strips ANSI codes, and maps known error patterns
5+
* to actionable user-facing messages shown in the crash dialog.
6+
*/
7+
8+
/**
9+
* List of known error patterns with human-readable messages and fix advice.
10+
* Each entry is matched against the raw stripped stderr output.
11+
*/
12+
const ERROR_HINTS = [
13+
{
14+
pattern: /bind: The requested address is not valid/,
15+
message: "Network address unavailable",
16+
advice:
17+
"The configured IP address doesn't exist on this machine. Check your network adapter or ADJ.",
18+
},
19+
{
20+
pattern: /failed to start UDP server/,
21+
message: "UDP server failed to start",
22+
advice: "Another process may already be using this port.",
23+
},
24+
{
25+
pattern: /jsonschema/,
26+
message: "ADJ Validator dependency missing",
27+
advice:
28+
"Install the required Python package by running: pip install jsonschema==4.25.0",
29+
},
30+
{
31+
pattern: /No Python interpreter found/,
32+
message: "Python not found",
33+
advice:
34+
"Install Python 3 and make sure it is accessible via 'python3', 'python', or 'py' in your PATH.",
35+
},
36+
{
37+
pattern: /ADJ Validator failed with error/,
38+
message: "ADJ validation failed",
39+
advice:
40+
"Your ADJ files contain schema errors. Check the ADJ validator log file in the logs folder for details.",
41+
},
42+
{
43+
pattern: /error reading config file/,
44+
message: "Config file not found",
45+
advice:
46+
"The configuration file could not be read. Check that the config file path is correct and the file exists.",
47+
},
48+
{
49+
pattern: /error unmarshaling toml file/,
50+
message: "Config file has errors",
51+
advice:
52+
"The configuration file contains invalid TOML. Check the config file for syntax or type errors.",
53+
},
54+
{
55+
pattern: /setting up ADJ/,
56+
message: "ADJ not available",
57+
advice:
58+
"Could not load the ADJ. If this is your first run, connect to the internet so the ADJ can be downloaded.",
59+
},
60+
];
61+
62+
/**
63+
* Reformats a single stripped zerolog console line into a readable block.
64+
* Zerolog console format: "TIME LEVEL FILE > message key=value ..."
65+
* @param {string} line - A single log line with ANSI codes already stripped.
66+
* @returns {string} A formatted multi-line string with level, file, and key-value pairs on separate lines.
67+
* @example
68+
* formatLine("11:43AM FTL setup_transport.go:143 > failed to start UDP server error=\"some error\"");
69+
* // "[FTL] at setup_transport.go:143\n failed to start UDP server\n error: \"some error\""
70+
*/
71+
function formatLine(line) {
72+
const m = line.match(/^\S+\s+(\S+)\s+(\S+)\s+>\s+(.*)/);
73+
if (!m) return line;
74+
const [, level, file, rest] = m;
75+
const body = rest.replace(
76+
/\s+(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g,
77+
"\n $1: $2",
78+
);
79+
return `[${level}] at ${file}\n ${body.trim()}`;
80+
}
81+
82+
/**
83+
* Formats a full multi-line stderr output by reformatting each zerolog line.
84+
* @param {string} text - Raw stderr text with ANSI codes already stripped.
85+
* @returns {string} Formatted text with each log line reformatted for readability.
86+
* @example
87+
* formatBackendError("11:43AM FTL file.go:10 > something failed error=\"bad\"");
88+
*/
89+
function formatBackendError(text) {
90+
return text.split("\n").filter(Boolean).map(formatLine).join("\n\n");
91+
}
92+
93+
/**
94+
* Returns a user-facing error message by matching the raw error against known patterns.
95+
* If a match is found, prepends a hint and advice to the formatted error.
96+
* Falls back to the formatted error text if no pattern matches.
97+
* @param {string} raw - Raw stripped stderr text used for pattern matching.
98+
* @param {string} formatted - Pre-formatted version of the error for display.
99+
* @returns {string} The final message to show in the crash dialog.
100+
* @example
101+
* getHint("failed to start UDP server ...", "[FTL] at ...");
102+
* // "UDP server failed to start\n\nAnother process may already be using this port.\n\n[FTL] at ..."
103+
*/
104+
function getHint(raw, formatted) {
105+
const match = ERROR_HINTS.find(({ pattern }) => pattern.test(raw));
106+
return match
107+
? `${match.message}\n\n${match.advice}\n\n${formatted}`
108+
: formatted;
109+
}
110+
111+
export { formatBackendError, getHint };

0 commit comments

Comments
 (0)