From 5d125637ee1bd67589a03064b57cdf383c80702b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:52:52 -0300 Subject: [PATCH 1/3] fix(genesis): retain the selected journal entry across history rollover A paused journal view restores a row number after refreshing its history. When older records leave the window, that row selects a different entry. Match the selected entry by its journal cursor and retain its wrapped-line position. If the entry has left the window, select the oldest retained row without resuming follow mode. --- .../files/xcat-genesis-console/src/newt_ui.c | 124 +++++++++++------- 1 file changed, 78 insertions(+), 46 deletions(-) diff --git a/xCAT-genesis-base/oe/meta-xcat-genesis/recipes-core/xcat-genesis-console/files/xcat-genesis-console/src/newt_ui.c b/xCAT-genesis-base/oe/meta-xcat-genesis/recipes-core/xcat-genesis-console/files/xcat-genesis-console/src/newt_ui.c index 11bc09282..b753e6bd8 100644 --- a/xCAT-genesis-base/oe/meta-xcat-genesis/recipes-core/xcat-genesis-console/files/xcat-genesis-console/src/newt_ui.c +++ b/xCAT-genesis-base/oe/meta-xcat-genesis/recipes-core/xcat-genesis-console/files/xcat-genesis-console/src/newt_ui.c @@ -266,19 +266,36 @@ static bool genesis_journal_entry(sd_journal *journal, char *source, size_t sour return true; } -static void format_logs(char *text, size_t size) { +struct log_record { + char text[LOG_LINE_SIZE]; + char *cursor; + unsigned int first_row; + unsigned int row_count; + bool selected; +}; + +struct log_view { + struct log_record records[LOG_LINE_COUNT]; + unsigned int count; + unsigned int start; +}; + +static void free_log_view(struct log_view *view) { + for (unsigned int index = 0; index < LOG_LINE_COUNT; index++) + free(view->records[index].cursor); +} + +static void load_logs(struct log_view *view, const char *selected_cursor) { sd_journal *journal = NULL; - char lines[LOG_LINE_COUNT][LOG_LINE_SIZE] = {{0}}; unsigned int count = 0; unsigned int next = 0; int result; - if (size == 0) - return; - text[0] = '\0'; result = sd_journal_open(&journal, SD_JOURNAL_LOCAL_ONLY | SD_JOURNAL_SYSTEM); if (result < 0) { - xcat_set_text(text, size, "Genesis journal is unavailable: %s", strerror(-result)); + xcat_set_text(view->records[0].text, LOG_LINE_SIZE, "Genesis journal is unavailable: %s", + strerror(-result)); + view->count = 1; return; } @@ -289,6 +306,7 @@ static void format_logs(char *text, size_t size) { char source[96] = ""; char timestamp[16] = "--:--:--"; uint64_t realtime; + struct log_record *record = &view->records[next]; if (!genesis_journal_entry(journal, source, sizeof(source)) || !journal_value(journal, "MESSAGE", message, sizeof(message))) @@ -300,32 +318,25 @@ static void format_logs(char *text, size_t size) { if (localtime_r(&seconds, &local_time) != NULL) strftime(timestamp, sizeof(timestamp), "%H:%M:%S", &local_time); } - snprintf(lines[next], sizeof(lines[next]), "%s %-24.24s %s", timestamp, source, message); + free(record->cursor); + record->cursor = NULL; + sd_journal_get_cursor(journal, &record->cursor); + record->selected = + selected_cursor != NULL && sd_journal_test_cursor(journal, selected_cursor) > 0; + snprintf(record->text, sizeof(record->text), "%s %-24.24s %s", timestamp, source, message); next = (next + 1) % LOG_LINE_COUNT; count++; } sd_journal_close(journal); if (count == 0) { - xcat_set_text(text, size, "No Genesis journal entries are available."); + xcat_set_text(view->records[0].text, LOG_LINE_SIZE, + "No Genesis journal entries are available."); + view->count = 1; return; } - { - unsigned int available = count < LOG_LINE_COUNT ? count : LOG_LINE_COUNT; - unsigned int start = count < LOG_LINE_COUNT ? 0 : next; - unsigned int index; - size_t used = 0; - - for (index = 0; index < available && used + 1 < size; index++) { - unsigned int line = (start + index) % LOG_LINE_COUNT; - int written = - snprintf(text + used, size - used, "%s%s", index > 0 ? "\n" : "", lines[line]); - - if (written < 0 || (size_t)written >= size - used) - break; - used += (size_t)written; - } - } + view->count = count < LOG_LINE_COUNT ? count : LOG_LINE_COUNT; + view->start = count < LOG_LINE_COUNT ? 0 : next; } static unsigned int append_wrapped_log_record(newtComponent list_box, const char *record, @@ -365,30 +376,48 @@ static unsigned int append_wrapped_log_record(newtComponent list_box, const char return count; } -static unsigned int populate_log_listbox(newtComponent list_box, const char *text) { - const char *record = text; +static unsigned int populate_log_listbox(newtComponent list_box, struct log_view *view) { unsigned int count = 0; - while (*record != '\0') { - const char *end = strchr(record, '\n'); - size_t length = end != NULL ? (size_t)(end - record) : strlen(record); + for (unsigned int index = 0; index < view->count; index++) { + struct log_record *record = &view->records[(view->start + index) % LOG_LINE_COUNT]; - count = append_wrapped_log_record(list_box, record, length, count); - if (end == NULL) - break; - record = end + 1; + record->first_row = count + 1; + count = append_wrapped_log_record(list_box, record->text, strlen(record->text), count); + record->row_count = count + 1 - record->first_row; } - if (count == 0) - count = append_wrapped_log_record(list_box, "", 0, count); return count; } +static const struct log_record *selected_log_record(const struct log_view *view, + unsigned int selected) { + for (unsigned int index = 0; index < view->count; index++) { + const struct log_record *record = &view->records[(view->start + index) % LOG_LINE_COUNT]; + + if (selected >= record->first_row && selected - record->first_row < record->row_count) + return record; + } + return NULL; +} + +static unsigned int retained_log_row(const struct log_view *view, unsigned int offset) { + for (unsigned int index = 0; index < view->count; index++) { + const struct log_record *record = &view->records[(view->start + index) % LOG_LINE_COUNT]; + + if (record->selected) { + if (offset >= record->row_count) + offset = record->row_count - 1; + return record->first_row + offset; + } + } + return 1; +} + static void show_logs(void) { struct newtExitStruct event; newtComponent form; newtComponent list_box; - char text[LOG_LINE_COUNT * LOG_LINE_SIZE]; - char previous[LOG_LINE_COUNT * LOG_LINE_SIZE] = ""; + struct log_view previous = {0}; unsigned int item_count = 0; unsigned int selected = 0; bool follow = true; @@ -410,15 +439,17 @@ static void show_logs(void) { newtPushHelpLine(" F3 Back Esc Back Up/Down Scroll End Follow"); while (!xcat_console_stop_requested) { - format_logs(text, sizeof(text)); - if (previous[0] == '\0' || strcmp(previous, text) != 0) { - newtListboxClear(list_box); - item_count = populate_log_listbox(list_box, text); - if (follow || selected == 0 || selected > item_count) - selected = item_count; - newtListboxSetCurrentByKey(list_box, (void *)(uintptr_t)selected); - xcat_copy_printable(previous, sizeof(previous), text); - } + struct log_view current = {0}; + const struct log_record *record = selected_log_record(&previous, selected); + unsigned int offset = record != NULL ? selected - record->first_row : 0; + + load_logs(¤t, !follow && record != NULL ? record->cursor : NULL); + newtListboxClear(list_box); + item_count = populate_log_listbox(list_box, ¤t); + selected = follow ? item_count : retained_log_row(¤t, offset); + newtListboxSetCurrentByKey(list_box, (void *)(uintptr_t)selected); + free_log_view(&previous); + previous = current; newtRefresh(); newtFormRun(form, &event); if (event.reason == NEWT_EXIT_ERROR || @@ -452,6 +483,7 @@ static void show_logs(void) { newtListboxSetCurrentByKey(list_box, (void *)(uintptr_t)selected); } + free_log_view(&previous); newtFormDestroy(form); newtPopHelpLine(); newtPopWindow(); From 53a2b75d4906df6f3485bfdeab263bafdaaa8205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:55:07 -0300 Subject: [PATCH 2/3] test(genesis): cover paused journal selection during rollover --- xCAT-test/native/genesis_console_dialogs.py | 139 ++++++++++++++++++-- 1 file changed, 129 insertions(+), 10 deletions(-) diff --git a/xCAT-test/native/genesis_console_dialogs.py b/xCAT-test/native/genesis_console_dialogs.py index f79ffcddd..1108c39a2 100644 --- a/xCAT-test/native/genesis_console_dialogs.py +++ b/xCAT-test/native/genesis_console_dialogs.py @@ -38,7 +38,9 @@ class ConsoleTerminal(unittest.TestCase): self.addCleanup(temporary.cleanup) root = Path(temporary.name) self.root = root - environment = {"PATH": os.environ["PATH"], "LC_ALL": "C.UTF-8", "TERM": "xterm"} + environment = {"PATH": os.environ["PATH"], "LC_ALL": "C.UTF-8", "TERM": "xterm", + "NEWT_COLORS": "listbox=black,white\nactlistbox=black,white\n" + "sellistbox=white,red\nactsellistbox=white,red"} for name in ("CMDLINE_FILE", "UPTIME_FILE", "OS_RELEASE", "STATUS_DIR", "STATE_FILE", "DESTINY_FILE", "RESPONSE_FILE", "SYS_ROOT", "PROC_ROOT", "EXTENSION_DIR", "PROVIDER_DIR"): environment["XCAT_" + name] = str(root / name.lower()) @@ -75,13 +77,16 @@ class ConsoleTerminal(unittest.TestCase): return "\n".join(self.screen.display) def wait_text(self, text): + return self.wait_screen(lambda display: text in display, repr(text)) + + def wait_screen(self, predicate, description): deadline = time.monotonic() + 5 while time.monotonic() < deadline: display = self.read_screen(0.1) - if text in display: + if predicate(display): return display self.assertIsNone(self.process.poll(), display) - self.fail(f"Terminal did not show {text!r}:\n{display}") + self.fail(f"Terminal did not show {description}:\n{display}") def key(self, sequence): os.write(self.master, sequence) @@ -117,7 +122,8 @@ class ConsoleJournal(ConsoleTerminal): """Read real journal files in a private machine and journal namespace. Requires bubblewrap, systemd-journal-remote, journalctl and user namespaces. - The host filesystem is read-only; /run and /var/log are private mounts. + The host filesystem is read-only except for the fixture directory. + /run and /var/log are private tmpfs mounts with the journal bound read-only. No journal service is started and no host journal is read or changed. """ @@ -128,11 +134,13 @@ class ConsoleJournal(ConsoleTerminal): journal_directory = journal_root / self.machine_id journal_directory.mkdir(parents=True) self.journal = journal_directory / "system.journal" + self.entries = [] machine_id_file = self.root / "machine-id" machine_id_file.write_text(self.machine_id + "\n") sandbox = [ "bwrap", "--unshare-all", "--die-with-parent", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/run", "--tmpfs", "/var/log", + "--bind", str(self.root), str(self.root), "--ro-bind", str(machine_id_file), "/etc/machine-id", "--ro-bind", str(journal_root), "/var/log/journal", ] @@ -140,34 +148,145 @@ class ConsoleJournal(ConsoleTerminal): capture_output=True, timeout=10) self.assertEqual(result.returncode, 0, "Journal tests require working Bubblewrap namespaces: " + result.stderr) + self.sandbox = sandbox return sandbox + [str(self.binary)] - def write_journal(self, entries): + def write_journal(self, entries, interval=1000000): records = [] - for sequence, (message, unit, identifier) in enumerate(entries, 1): + for sequence, (message, unit, identifier) in enumerate(entries, len(self.entries) + 1): records.append( - f"__REALTIME_TIMESTAMP={1700000000000000 + sequence * 1000000}\n" - f"__MONOTONIC_TIMESTAMP={sequence * 1000000}\n" + f"__REALTIME_TIMESTAMP={1700000000000000 + sequence * interval}\n" + f"__MONOTONIC_TIMESTAMP={sequence * interval}\n" f"_MACHINE_ID={self.machine_id}\n_BOOT_ID={self.boot_id}\n" f"_SYSTEMD_UNIT={unit}\nSYSLOG_IDENTIFIER={identifier}\n" f"MESSAGE={message}\n\n") result = subprocess.run( - ["/usr/lib/systemd/systemd-journal-remote", "--split-mode=none", + self.sandbox + ["/usr/lib/systemd/systemd-journal-remote", "--split-mode=none", "--output=" + str(self.journal), "-"], input="".join(records), text=True, capture_output=True, timeout=10) self.assertEqual(result.returncode, 0, result.stderr) + self.entries.extend(entries) exported = subprocess.check_output( ["journalctl", "--file=" + str(self.journal), "--output=json", "--no-pager"], text=True, timeout=10) actual = [json.loads(line) for line in exported.splitlines()] self.assertEqual([(entry["MESSAGE"], entry["_SYSTEMD_UNIT"], entry["SYSLOG_IDENTIFIER"]) - for entry in actual], entries) + for entry in actual], self.entries) def open_logs(self): self.key(b"\x1bOR") self.wait_text("Genesis logs") self.wait_text("F3 Back") + def selected_text(self): + return "\n".join("".join(self.screen.buffer[row][column].data + for column in range(self.screen.columns) + if self.screen.buffer[row][column].bg == "red").strip() + for row in range(self.screen.lines)).strip() + + def wait_selected(self, text): + return self.wait_screen(lambda display: self.selected_text().endswith(text), + f"selected log entry {text!r}") + + def assert_selection_stays(self, text): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + self.read_screen(0.1) + self.assertEqual(self.selected_text(), text) + self.assertIsNone(self.process.poll()) + + def test_paused_entry_survives_history_rollover(self): + self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register") + for number in range(1, 130)]) + self.open_logs() + self.wait_selected("RECORD-0129") + self.key(b"\x1b[H") + self.wait_selected("RECORD-0002") + self.key(b"\x1b[B" * 60) + self.wait_selected("RECORD-0062") + selected = self.selected_text() + for number in (130, 131): + self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register")]) + self.assert_selection_stays(selected) + self.key(b"\x1b[F") + self.wait_selected("RECORD-0131") + self.write_journal([("FOLLOWED", "other.service", "xcat-genesis-register")]) + self.wait_selected("FOLLOWED") + + def test_expired_selection_stays_paused_at_oldest_entry(self): + self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register") + for number in range(1, 130)]) + self.open_logs() + self.wait_selected("RECORD-0129") + self.key(b"\x1b[H") + self.wait_selected("RECORD-0002") + for first, last, oldest in ((130, 140, 12), (140, 270, 142)): + self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register") + for number in range(first, last)]) + self.wait_selected(f"RECORD-{oldest:04d}") + self.assert_selection_stays(self.selected_text()) + if first == 130: + self.key(b"\x1b[B" * 50) + self.wait_selected("RECORD-0062") + self.write_journal([("STILL-PAUSED", "other.service", "xcat-genesis-register")]) + self.wait_selected("RECORD-0143") + self.key(b"\x1b[F") + self.wait_selected("STILL-PAUSED") + + def test_duplicate_text_keeps_the_selected_record(self): + self.write_journal([("DUPLICATE" if number in (60, 62, 63) else f"RECORD-{number:04d}", + "other.service", "xcat-genesis-register") + for number in range(1, 130)], interval=1) + self.open_logs() + self.wait_selected("RECORD-0129") + self.key(b"\x1b[H") + self.wait_selected("RECORD-0002") + self.key(b"\x1b[B" * 58) + self.wait_selected("DUPLICATE") + duplicate = self.selected_text() + self.key(b"\x1b[B") + self.wait_selected("RECORD-0061") + self.key(b"\x1b[B") + self.wait_selected("DUPLICATE") + self.assertEqual(self.selected_text(), duplicate) + self.write_journal([("APPENDED", "other.service", "xcat-genesis-register")], interval=1) + self.assert_selection_stays(duplicate) + self.key(b"\x1b[A") + self.wait_selected("RECORD-0061") + self.key(b"\x1b[H") + self.wait_selected("RECORD-0003") + + def test_wrapped_selection_survives_history_rollover(self): + self.write_journal([ + (" ".join(f"OLD-{number:02d}" for number in range(20)), + "other.service", "xcat-genesis-register"), + (" ".join(f"CONTEXT-{number:02d}" for number in range(16)) + " SELECTED-TAIL", + "other.service", "xcat-genesis-register"), + ] + [(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register") + for number in range(3, 129)]) + self.open_logs() + self.wait_selected("RECORD-0128") + self.key(b"\x1b[H") + self.wait_screen(lambda display: "OLD-00" in self.selected_text(), "oldest wrapped entry") + for _ in range(12): + if self.selected_text().endswith("CONTEXT-08"): + break + previous = self.selected_text() + self.key(b"\x1b[B") + self.wait_screen(lambda display: self.selected_text() != previous, "next wrapped row") + self.assertTrue(self.selected_text().endswith("CONTEXT-08"), self.selected_text()) + selected = self.selected_text() + self.assertIn("OLD-00", self.read_screen(0)) + self.write_journal([("APPENDED", "other.service", "xcat-genesis-register")]) + self.wait_screen(lambda display: "OLD-00" not in display, "expired wrapped entry removed") + self.assert_selection_stays(selected) + self.key(b"\x1b[B") + self.wait_selected("CONTEXT-14") + self.key(b"\x1b[B") + self.wait_selected("SELECTED-TAIL") + self.key(b"\x1b[B") + self.wait_selected("RECORD-0003") + def test_empty_journal(self): self.open_logs() self.wait_text("No Genesis journal entries are available.") From f9024b9fd29bf68c486fff4ee1ac40d31878105f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:55:07 -0300 Subject: [PATCH 3/3] docs(test): describe journal selection rollover coverage --- xCAT-test/native/README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md index 58a458713..4af5c8d7e 100644 --- a/xCAT-test/native/README.md +++ b/xCAT-test/native/README.md @@ -278,14 +278,17 @@ dependencies plus the `pyte` Python package, bubblewrap, systemd-journal-remote and journalctl. It runs the linked console in a pseudo-terminal and checks help and diagnostics navigation, scrolling, return to the main screen, and F3 journal filtering, order and history limits. +Rollover cases check paused selection, expired entries, duplicate messages, +wrapped lines and returning to follow mode. It checks termination from the main screen, not from inside a dialog. The journal cases need working Linux user and mount namespaces. On Ubuntu 24.04, the CI job enables them with `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`. -They import private journal files and verify their contents with journalctl -before opening F3. They do not start a journal service or read the host -journal. Missing tools or namespace support fail the tests; they do not skip. +They import private journal files, including appends while F3 is open, and +verify each import with journalctl. They do not start a journal service or +read the host journal. Missing tools or namespace support fail the tests; +they do not skip. The Genesis runtime BATS cases need Bash, Perl, OpenSSL, coreutils, diffutils and bubblewrap. The credential-file cases use a private `/etc` in a Linux