fix(console): the final flush speaks in whole lines, from the real buffer

tailAfterStream sliced result.stdout at the live-buffer offset, but the
buffer interleaves stdout+stderr in arrival order while result.stdout is
stdout-only plus a joining newline — offsets never line up, so the flush
could start mid-line ('f/esptool_py && ...'). Instead, the poll loop now
emits one final onProgress(state:'done') with the completed buffer and
the existing delta logic flushes the tail perfectly aligned. tailAfterStream
is gone.
This commit is contained in:
David Montero Crespo 2026-08-09 08:05:43 +02:00
parent 23784568f6
commit 6d6d298130
3 changed files with 25 additions and 36 deletions

View File

@ -24,7 +24,7 @@ import { reportRunEvent } from '../../services/metricsService';
import { useProjectStore } from '../../store/useProjectStore';
import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal';
import { parseCompileResult, isNoiseBuildLine, tailAfterStream } from '../../utils/compilationLogger';
import { parseCompileResult, isNoiseBuildLine } from '../../utils/compilationLogger';
import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger';
import { exportToWokwiZip } from '../../utils/wokwiZip';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
@ -580,14 +580,14 @@ export const EditorToolbar = ({
},
);
// After the build settles, flush the stdout tail the polling loop never
// delivered, then append the structured analysis — parseCompileResult
// highlights FAILED blocks and tags compiler errors with type='error',
// which the console uses for colour + the auto-switch-to-errors filter.
// streamedLive tells it not to reprint the stdout shown above.
const tailLogs = tailAfterStream(result.stdout, lastStreamedLen, boardTarget);
// After the build settles, append the structured analysis on top of
// the live stream — parseCompileResult highlights FAILED blocks and
// tags compiler errors with type='error', which the console uses for
// colour + the auto-switch-to-errors filter. streamedLive tells it not
// to reprint the stdout the stream (final 'done' flush included)
// already showed.
const resultLogs = parseCompileResult(result, boardLabel, boardTarget, lastStreamedLen > 0);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...tailLogs, ...resultLogs]);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
const program = result.hex_content ?? result.binary_content ?? null;
@ -1080,9 +1080,8 @@ export const EditorToolbar = ({
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined },
);
const tailLogs = tailAfterStream(result.stdout, lastStreamedLen, boardTarget);
const resultLogs = parseCompileResult(result, label, boardTarget, lastStreamedLen > 0);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...tailLogs, ...resultLogs]);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
const program = result.hex_content ?? result.binary_content ?? null;

View File

@ -56,12 +56,15 @@ interface CompileStatusResponse {
/**
* Live progress callback — called on every poll while state ∈ {pending,
* running}. `stdout` is the full live cmake + ninja output captured so far
* (cap of ~256 KB on the server side, tail kept). Caller can compute a
* delta against the previous call if it wants to append-only render.
* running}, plus one final call with state 'done' carrying the complete
* buffer (the lines that landed between the last running-poll and job
* completion would otherwise never be delivered). `stdout` is the full live
* cmake + ninja output captured so far (cap of ~256 KB on the server side,
* tail kept). Caller can compute a delta against the previous call if it
* wants to append-only render.
*/
export type CompileProgress = (info: {
state: 'pending' | 'running';
state: 'pending' | 'running' | 'done';
stdout: string;
elapsedSeconds: number;
}) => void;
@ -172,6 +175,15 @@ export async function compileCode(
if (status.state === 'done' && status.result) {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
console.log(`[compile] job ${jobId} done in ${elapsed}s`);
// Final flush: the buffer grew between the last running-poll and
// completion (esptool + binary-size lines usually live there).
if (onProgress) {
try {
onProgress({ state: 'done', stdout: status.stdout || '', elapsedSeconds: elapsed });
} catch (err) {
console.warn('[compile] onProgress threw:', err);
}
}
return status.result;
}

View File

@ -46,28 +46,6 @@ export function isNoiseBuildLine(line: string): boolean {
return NOISE_LINE_PATTERNS.some((re) => re.test(stripped));
}
/** Lines of result.stdout past `streamedLen` — the tail that lands between
* the last live-status poll and job completion, which the polling loop never
* delivers (esptool output + the binary-size summary usually live there).
* Skipped when the backend's live buffer hit its 256 KB tail-trim cap: past
* that point buffer offsets no longer line up with the full stdout, and a
* slightly short log beats a misaligned one. */
export function tailAfterStream(
stdout: string | undefined,
streamedLen: number,
target?: CompileTarget,
): CompilationLog[] {
const BUFFER_CAP = 262_144;
if (!stdout || streamedLen <= 0 || streamedLen >= BUFFER_CAP) return [];
if (stdout.length <= streamedLen) return [];
const now = new Date();
return stdout
.slice(streamedLen)
.split('\n')
.filter((s) => s.trim() && !isNoiseBuildLine(s))
.map((message) => ({ timestamp: now, type: 'info' as const, message, target }));
}
export function parseCompileResult(
result: CompileResult,
board: string,