FABRIC-2.md §I.9: fix the terminal phantom-linebreak defect
Build / build-amd64-iso (push) Waiting to run
Build / build-aarch64-iso (push) Waiting to run
Build / build-riscv64-img (push) Waiting to run

console_ensure_line_start() (hal/console.c) used to emit its newline
immediately via a path that deliberately skipped the tx-byte counter, so
that sk_repl_idle()'s unconditional per-beat call to it (repl.c, ~1s idle
heartbeat) could force a real newline the REPL's own prompt-reanchor logic
never noticed -- the prompt was never reprinted, and the next real
keystroke echoed onto the now-blank line, indistinguishable from Enter
having already been pressed at a bare prompt. Root-caused in the previous
commit (704573b); this commit applies the fix per explicit go-ahead.

Fix: defer the newline instead of emitting it eagerly.
console_ensure_line_start() now only sets a flag (g_pending_line_close);
the newline is realized -- for real, and counted by g_console_tx_count
like any other output -- on the next actual console_putc() call, or
silently discarded via the new console_cancel_deferred_line_start() if the
caller decides nothing was actually printed. sk_repl_idle() captures
tx_before_idle right after its console_ensure_line_start() call and cancels
the deferred newline at both of its exit points when console_tx_count()
hasn't moved. A beat with nothing to report now leaves the console
untouched; a beat that does print still closes the dangling prompt line
first, properly counted this time. The pre-existing n > 0 mid-edit gate in
sk_console_readline() is untouched -- independent purpose, not the bug.

Verified via the mandatory foreground 3-arch QEMU acceptance boot
(clean qemu, amd64 -> aarch64 -> riscv64, one at a time): all three reached
(zuse) ok>, all echoed the first real input on the same log line as the
prompt rather than a fresh line, all shut down cleanly via BYE. amd64's log
additionally shows live human backspace-correction still glued to the same
prompt line. Logs: logs/20260905-015854 (amd64), logs/20260905-020248
(aarch64), logs/20260905-020552 (riscv64); logs/20260905-015813 is a
foreground-rule-violation retry killed and redone correctly, kept per this
project's "never delete logs/" convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YcT3H2PQeyujrzjqS3Var
This commit is contained in:
Robert Allan James
2026-09-05 02:08:06 -04:00
co-authored by Claude Sonnet 5
parent 704573bdfc
commit 8edb95b65d
13 changed files with 27108 additions and 26 deletions
+33
View File
@@ -4716,3 +4716,36 @@ same root cause, not a separately confirmed one.
diagnosis is complete and reproducible on demand (QMP `send-key` + a screenshot, no physical diagnosis is complete and reproducible on demand (QMP `send-key` + a screenshot, no physical
keyboard needed, on any of the three architectures); whether and how to fix it is a decision keyboard needed, on any of the three architectures); whether and how to fix it is a decision
for whoever picks this item up next. for whoever picks this item up next.
**FIXED, 2026-09-05, explicit go-ahead given ("Yes, fix it").** Deferred-newline mechanism:
`console_ensure_line_start()` (`include/starkernel/console.h`, `src/starkernel/hal/console.c`)
no longer calls `console_putc_inner('\n')` immediately. It now only sets a flag
(`g_pending_line_close`); the newline is realized — for real, and counted by
`g_console_tx_count` like any other output — the next time `console_putc()` actually runs, or
silently discarded via the new `console_cancel_deferred_line_start()` if the caller decides it
has nothing to print after all. `sk_repl_idle()` (`repl.c`) now captures
`tx_before_idle = console_tx_count()` right after its `console_ensure_line_start()` call, and
calls `console_cancel_deferred_line_start()` at both of its exit points if
`console_tx_count() == tx_before_idle` (nothing was actually printed that beat). This means a
beat with nothing to report leaves the console completely untouched — no stray newline, no
phantom blank line — while a beat that does print (xhci attach/detach, block-sync notices)
still gets the correct "close the dangling prompt line first" behavior, now properly counted so
the reanchor logic sees it. The pre-existing `n > 0` mid-edit gate at `sk_console_readline()`
(`repl.c`, originally documented at old lines 598-609) is untouched — it serves an independent
purpose (deferring bottom-half servicing during active typing) and was never the bug.
Verified via the mandatory 3-arch QEMU acceptance boot, all foreground, `clean qemu` each
(amd64 `logs/20260905-015854/`, aarch64 `logs/20260905-020248/`, riscv64
`logs/20260905-020552/`). All three reached `(zuse) ok>` and, per the raw serial log bytes,
echoed the first real input on the *same log line* as the prompt — e.g. aarch64's
`(zuse) ok> 1 1 + .r CR` and riscv64's `(zuse) ok> 1 1 . CR BYE`, not a fresh `[Hera]`-prefixed
line as before the fix — then shut down cleanly via `BYE`. amd64's raw log additionally shows
genuine human backspace-correction (`11^H ^H 1 + .`) still glued to the same prompt line,
confirming the fix holds under real typing, not just a scripted single keystroke. No separate
QMP send-key/screendump re-run was needed — these boots were driven live at the actual QEMU GTK
window.
The secondary, unverified cursor-glyph-staleness observation above was not independently
re-checked — out of scope for this fix, and about to be superseded anyway by a separate
requested change (blinking `|` cursor instead of a static block), tracked as a new item, not
here.
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated # Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-09-05T05:38:13Z --> <!-- Generated by mkcapsule --manifest 2026-09-05T06:05:19Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. --> <!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live --> <!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. --> <!-- in MANIFEST.md alongside this auto-generated index. -->
BIN
View File
Binary file not shown.
+29 -5
View File
@@ -129,14 +129,38 @@ void console_puts(const char *s);
void console_println(const char *s); void console_println(const char *s);
/** /**
* console_ensure_line_start - Make sure the next console_putc() starts at * console_ensure_line_start - Make sure the *next real output* starts at
* the beginning of a fresh output line, emitting a newline if output is * the beginning of a fresh output line, closing off whatever is currently
* currently mid-line (e.g. dangling behind a re-anchored prompt). * mid-line (e.g. a dangling prompt). No-op if already at line start.
* No-op if already at line start. Mirrors console_putc()'s dual *
* serial+framebuffer behavior (a bare '\n' reaches both). * FABRIC-2.md §I.9 fix, 2026-09-05: the newline is DEFERRED, not emitted
* immediately -- it only actually reaches the console on the next real
* console_putc() call, and is silently dropped (never emitted at all) if
* console_cancel_deferred_line_start() is called first instead. Before this
* fix, an immediate, unconditional newline here meant any caller invoking
* this function "just in case" (sk_repl_idle() being the one real caller)
* would visibly snap a bare, unfinished prompt line to a fresh blank line
* even when nothing was actually about to be printed -- indistinguishable
* from Enter having already been pressed at that prompt. Deferring means a
* caller that turns out to have nothing to print can cancel cleanly, with
* zero visible effect, while a caller that does print gets the correct
* "close the old line first" behavior for free, still counted by
* console_tx_count() as real output (unlike the old always-silent inner
* form) since it is realized through the normal console_putc() path.
*/ */
void console_ensure_line_start(void); void console_ensure_line_start(void);
/**
* console_cancel_deferred_line_start - Discard a pending deferred newline
* from console_ensure_line_start() without ever emitting it. No-op if no
* newline is currently deferred. Callers that speculatively deferred a line
* break before checking whether they actually have anything to print
* (sk_repl_idle()'s idle-beat check being the motivating case, FABRIC-2.md
* §I.9) call this when the check comes back negative, so the console is
* left exactly as it was -- no stray newline, no phantom blank line.
*/
void console_cancel_deferred_line_start(void);
/** /**
* console_tx_count - Monotonic count of console_putc() calls delivered to * console_tx_count - Monotonic count of console_putc() calls delivered to
* either output (serial and/or framebuffer). In use by the REPL to detect * either output (serial and/or framebuffer). In use by the REPL to detect
@@ -0,0 +1,12 @@
[=3hBdsDxe: loading Boot0002 "UEFI QEMU DVD-ROM QM00005 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x2,0xFFFF,0x0)
BdsDxe: starting Boot0002 "UEFI QEMU DVD-ROM QM00005 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x2,0xFFFF,0x0)
[=3hStarKernel UEFI Loader
Loading kernel from ESP...
[CKPT 001] Entered efi_main - ConOut live
RAW SERIAL UP
[CKPT 002] Serial (COM1) initialized
Monolithic build - kernel linked directly
Collecting boot information...
CmdLine: parsed OK
[CKPT 004] Command line parsed
[CKPT 005] Kernel stack allocation decided
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -260,16 +260,33 @@ static void emit_prefix(void) {
* Also mirrors output to the framebuffer VT100 terminal when available. * Also mirrors output to the framebuffer VT100 terminal when available.
* Serial output is ALWAYS active regardless of framebuffer state. * Serial output is ALWAYS active regardless of framebuffer state.
* *
* console_putc_inner(): the same body minus the tx-counter increment. * console_putc_inner(): the same body minus the tx-counter increment --
* console_ensure_line_start() uses the inner form so its format-only * used internally for the newline console_putc() itself realizes out of
* newline does not read as "real output" to the REPL's prompt re-anchor * g_pending_line_close below (see console_ensure_line_start()), so that
* (repl.c) -- a silent idle beat that merely closes a dangling prompt line * one real character write emits at most one counted "start" (the '\n'
* must not be mistaken for chatter and trigger a prompt repaint. The * plus the character together read as a single unit of real output, not
* counter counts characters the caller actually intended to emit. * two, to console_tx_count()'s callers).
*/ */
static void console_putc_inner(char c); static void console_putc_inner(char c);
/* FABRIC-2.md §I.9 fix, 2026-09-05: set by console_ensure_line_start(),
* cleared either by console_cancel_deferred_line_start() (no output
* followed -- drop the newline silently) or here in console_putc() (real
* output followed -- realize it for real, counted, immediately before the
* character that triggered it). See console.h's doc comments on both
* functions for the full rationale; the short version is that the old
* unconditional-and-immediate version of this newline fired every idle
* heartbeat regardless of whether anything was actually about to be
* printed, visibly snapping a bare REPL prompt to a fresh blank line on
* its own. */
static int g_pending_line_close = 0;
void console_putc(char c) { void console_putc(char c) {
if (g_pending_line_close) {
g_pending_line_close = 0;
g_console_tx_count++;
console_putc_inner('\n');
}
g_console_tx_count++; g_console_tx_count++;
console_putc_inner(c); console_putc_inner(c);
} }
@@ -313,10 +330,15 @@ void console_ensure_line_start(void)
{ {
if (!g_line_start) if (!g_line_start)
{ {
console_putc_inner('\n'); g_pending_line_close = 1;
} }
} }
void console_cancel_deferred_line_start(void)
{
g_pending_line_close = 0;
}
/** /**
* Write a string with newline to serial console * Write a string with newline to serial console
*/ */
+38 -13
View File
@@ -174,8 +174,18 @@ static void sk_repl_idle(VM *active_vm)
* the prompt and inherit no {VMName} prefix (g_line_start is 0). A * the prompt and inherit no {VMName} prefix (g_line_start is 0). A
* fresh line first keeps every idle line prefix-tagged and readable, * fresh line first keeps every idle line prefix-tagged and readable,
* matching what an interactive typing session expects. No-op when the * matching what an interactive typing session expects. No-op when the
* console is already at a line boundary. */ * console is already at a line boundary.
*
* FABRIC-2.md §I.9 fix, 2026-09-05: this newline is now deferred (see
* console_ensure_line_start()'s own doc comment) -- it only actually
* reaches the console if something below really prints. tx_before_idle
* lets this function tell "nothing happened" apart from "something did"
* the same way the reanchor check further up this file already does,
* so a beat with nothing to report can cancel the deferred newline
* before returning, leaving the bare prompt line completely untouched
* instead of visibly snapping it to a fresh blank line every ~1s. */
console_ensure_line_start(); console_ensure_line_start();
uint64_t tx_before_idle = console_tx_count();
/* Artemis Milestone 2d: xHCI Event Ring servicing. This is exactly the /* Artemis Milestone 2d: xHCI Event Ring servicing. This is exactly the
* "interrupt-driven, coarse cadence, cheap early-exit" trigger Section * "interrupt-driven, coarse cadence, cheap early-exit" trigger Section
@@ -357,8 +367,12 @@ static void sk_repl_idle(VM *active_vm)
* a live parse truncates the rest of that line. Deferring the MSG-TICK * a live parse truncates the rest of that line. Deferring the MSG-TICK
* drain to the next prompt boundary is safe -- draining is best-effort * drain to the next prompt boundary is safe -- draining is best-effort
* and simply resumes next beat. */ * and simply resumes next beat. */
if (g_mama_interpreting || g_idle_pump_active) if (g_mama_interpreting || g_idle_pump_active) {
if (console_tx_count() == tx_before_idle) {
console_cancel_deferred_line_start();
}
return; return;
}
g_idle_pump_active = 1; g_idle_pump_active = 1;
{ {
@@ -386,6 +400,10 @@ static void sk_repl_idle(VM *active_vm)
} }
} }
g_idle_pump_active = 0; g_idle_pump_active = 0;
if (console_tx_count() == tx_before_idle) {
console_cancel_deferred_line_start();
}
} }
/*=========================================================================== /*===========================================================================
@@ -596,17 +614,24 @@ int sk_console_readline(char* buf, int size, VM* active_vm, int reanchor_prompt)
uint64_t now = heartbeat_ticks(); uint64_t now = heartbeat_ticks();
/* n == 0 gate: sk_repl_idle() opens with console_ensure_line_start(), /* n == 0 gate: sk_repl_idle() opens with console_ensure_line_start(),
* which unconditionally forces a newline whenever the console isn't * closing off a dangling prompt line before any chatter it might
* already at a line boundary -- including mid-edit, with characters * print (xhci attach/detach, block-sync, MSG-TICK pump). Before the
* already typed and echoed (n > 0) but Enter not yet pressed. Without * FABRIC-2.md §I.9 fix (2026-09-05), that newline was unconditional
* this gate, that forced break fires on every elapsed * and immediate, so it fired on every elapsed SK_IDLE_BEAT_INTERVAL
* SK_IDLE_BEAT_INTERVAL regardless of whether sk_repl_idle() actually * regardless of whether sk_repl_idle() actually had anything to
* has anything to print, visually snapping the in-progress input line * print -- including mid-edit (n > 0, characters typed but Enter
* to a fresh (empty) line -- indistinguishable from Enter having been * not yet pressed), visually snapping the in-progress line to a
* pressed. Deferring the whole idle beat while a line is being edited * fresh blank one, indistinguishable from Enter having been
* (same n > 0 guard the prompt reanchor below already uses) delays * pressed. console_ensure_line_start()'s newline is now deferred
* xhci/block-sync servicing by at most one more interval, which its * and self-cancelling when nothing follows it (console.c), which
* own "coarse cadence, cheap early-exit" design already tolerates. */ * fixes that regardless of n -- but this gate is kept for its own,
* independent reason: deferring the *whole* idle beat while a line
* is being edited (same n > 0 guard the prompt reanchor below
* already uses) means a genuine xhci/block-sync/MSG-TICK event
* cannot interrupt output mid-line while the user is actively
* typing, only delaying that servicing by at most one more
* interval, which its own "coarse cadence, cheap early-exit"
* design already tolerates. */
if (n == 0 && now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) { if (n == 0 && now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) {
g_last_beat_tick = now; g_last_beat_tick = now;
sk_repl_idle(active_vm); sk_repl_idle(active_vm);