Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix: fix messages new lines corrupting format by adding escape and un…
…escape function helpers
  • Loading branch information
kwame-Owusu committed Jul 23, 2026
commit afd8af11aae4ca6cef021fa2103e719c01770e71
58 changes: 58 additions & 0 deletions src/log/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,64 @@ test("parseLogLine preserves tabs inside the message", () => {
assert.deepEqual(parsed, entry);
});

const messageRoundTripCases: { message: string; label: string }[] = [
{ label: "embedded newline", message: "error\nstacktrace" },
{ label: "embedded CR", message: "line1\rline2" },
{ label: "literal backslash-n", message: "path\\nother" },
{ label: "both literal backslash-n and real newline", message: "path\\nother\nand more" },
{ label: "multiple newlines", message: "a\nb\nc" },
{ label: "CRLF", message: "line1\r\nline2" },
];

for (const { message, label } of messageRoundTripCases) {
test(`formatLogLine/parseLogLine round trip: ${label}`, () => {
const entry: LogEntry = {
time: Date.parse("2026-07-14T10:00:00.000Z"),
level: "error",
message,
};

const parsed = parseLogLine(formatLogLine(entry));

assert.deepEqual(parsed, entry);
});
}

test("formatLogLine produces a single physical line for a message with newlines", () => {
const entry: LogEntry = {
time: Date.parse("2026-07-14T10:00:00.000Z"),
level: "error",
message: "ENOENT\n at readFileSync",
};

const line = formatLogLine(entry);

assert.equal(line.includes("\n"), false);
});

test("parseLogLine survives the adapter's split-on-newline path", () => {
const entry: LogEntry = {
time: Date.parse("2026-07-14T10:00:00.000Z"),
level: "error",
message: "sync: ENOENT\n at readFileSync\n at load",
};

const persisted = `${formatLogLine(entry)}\n`;

const entries: LogEntry[] = [];
for (const line of persisted.split("\n")) {
if (line !== "") {
const parsed = parseLogLine(line);
if (parsed !== undefined) {
entries.push(parsed);
}
}
}

assert.equal(entries.length, 1);
assert.deepEqual(entries[0], entry);
});

const malformedLines = [
"",
"not a log line",
Expand Down
58 changes: 54 additions & 4 deletions src/log/log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};

// LogEntry is one line of geode's log.
export type LogEntry = {
Expand Down Expand Up @@ -70,11 +75,24 @@ export function createMemorySink(maxLines: number): LogSink {
};
}

// escapeMessage encodes control characters in a log message so the result is a single physical
// line. Backslashes are escaped first so a pre-existing literal "\n" (two characters) is not
// misinterpreted when newlines are escaped next.
export function escapeMessage(msg: string): string {
return msg
.split("\\")
.join("\\\\")
.split("\n")
.join("\\n")
.split("\r")
.join("\\r");
}

// formatLogLine renders one entry as a single persisted line: an ISO timestamp, the level, then
// the message, tab separated so parseLogLine can split on the same delimiter without tripping
// over spaces in either the level or the message.
export function formatLogLine(entry: LogEntry): string {
return `${new Date(entry.time).toISOString()}\t${entry.level}\t${entry.message}`;
return `${new Date(entry.time).toISOString()}\t${entry.level}\t${escapeMessage(entry.message)}`;
}

// levelEnabled reports whether a message at level should be logged when the minimum is minLevel.
Expand All @@ -93,7 +111,34 @@ export function parseLogLine(line: string): LogEntry | undefined {
if (Number.isNaN(time) || !isLogLevel(rawLevel)) {
return undefined;
}
return { time, level: rawLevel, message: rest.join("\t") };
return { time, level: rawLevel, message: unescapeMessage(rest.join("\t")) };
}

// unescapeMessage reverses escapeMessage. Unlike escape, unescape must
// scan character by character to avoid matching "\n" inside the stored "\\" sequence.
export function unescapeMessage(msg: string): string {
let result = "";
for (let i = 0; i < msg.length; i++) {
if (msg[i] === "\\" && i + 1 < msg.length) {
const next = msg[i + 1];
if (next === "n") {
result += "\n";
i++;
} else if (next === "r") {
result += "\r";
i++;
} else if (next === "\\") {
result += "\\";
i++;
} else {
result += msg[i];
}
} else {
result += msg[i];
}
}

return result;
}

// trimLogLines keeps only the last maxLines lines of a log, dropping the oldest. The result keeps
Expand Down Expand Up @@ -124,7 +169,12 @@ function consoleFor(level: LogLevel): (message: string) => void {
}

function isLogLevel(value: string): value is LogLevel {
return value === "debug" || value === "info" || value === "warn" || value === "error";
return (
value === "debug" ||
value === "info" ||
value === "warn" ||
value === "error"
);
}

// linesOf splits a log's text into its lines. Every persisted line ends in "\n", so a naive split
Expand Down