From 9f92a2a08dad4536f22902c87d1eb033b7bd02aa Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:19:19 +0200 Subject: [PATCH 1/3] Release v0.2.3 --- platform.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform.json b/platform.json index 8c7fea6..6fc02e4 100644 --- a/platform.json +++ b/platform.json @@ -1,6 +1,6 @@ { "name": "ardulinux", - "version": "0.2.2", + "version": "0.2.3", "title": "ArduLinux", "description": "ArduLinux - Arduino API for Linux", "keywords": ["arduino", "linux", "native", "posix", "gpio", "spi", "i2c", "portduino"], From 1b832aa455cb2bb11b374f7082bbea20e7188d8c Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:19:43 +0200 Subject: [PATCH 2/3] tests: assert SimSerial line-buffers stdout --- tests/unit/test_sim_serial.cpp | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/unit/test_sim_serial.cpp b/tests/unit/test_sim_serial.cpp index ae59868..3f69adb 100644 --- a/tests/unit/test_sim_serial.cpp +++ b/tests/unit/test_sim_serial.cpp @@ -11,6 +11,10 @@ #include #include "linux/LinuxSerial.h" +#include +#include +#include +#include // ─── SimSerial API contract ─────────────────────────────────────────────────── @@ -70,3 +74,42 @@ TEST_CASE("arduino::Serial (global SimSerial) is always true", "[serial][sim]") TEST_CASE("arduino::Serial::available returns 0", "[serial][sim]") { CHECK(arduino::Serial.available() == 0); } + +// ─── stdout buffering (SimSerial constructor) ───────────────────────────────── +// +// The SimSerial constructor line-buffers stdout once, at static init, so that +// Serial output reaches a non-TTY sink (pipe, file, journald) on each newline +// rather than sitting in stdio's full-buffer until exit. Asserted +// behaviourally: point fd 1 at a pipe, emit a line, and read it straight back. +// The read must happen before any fflush() — flushing by hand would push the +// line through even when stdout is fully buffered, and mask a regression. + +TEST_CASE("SimSerial constructor line-buffers stdout", "[serial][sim][buffering]") { + // Drain anything the harness left pending so the pipe sees only our line. + fflush(stdout); + + int pipefd[2]; + REQUIRE(pipe(pipefd) == 0); + // A fully-buffered stdout puts nothing in the pipe; the read has to report + // that rather than block forever waiting for a writer that never writes. + REQUIRE(fcntl(pipefd[0], F_SETFL, O_NONBLOCK) == 0); + + int saved_stdout = dup(STDOUT_FILENO); + REQUIRE(saved_stdout != -1); + REQUIRE(dup2(pipefd[1], STDOUT_FILENO) != -1); + + // The trailing newline is what triggers the flush under _IOLBF. + arduino::Serial.println("LINEBUF"); + + char buf[64] = {0}; + ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); + + // Restore fd 1 before asserting, so a failure still reports to the console. + dup2(saved_stdout, STDOUT_FILENO); + close(saved_stdout); + close(pipefd[1]); + close(pipefd[0]); + + REQUIRE(n > 0); + CHECK(std::string(buf).find("LINEBUF") != std::string::npos); +} From e7ed21e296e58476e0b8854ae700f119bb992b89 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:25:27 +0200 Subject: [PATCH 3/3] SimSerial: make flush() actually flush stdout --- cores/ardulinux/linux/LinuxSerial.cpp | 14 ++++- cores/ardulinux/linux/LinuxSerial.h | 2 +- tests/unit/test_sim_serial.cpp | 88 +++++++++++++++++++-------- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/cores/ardulinux/linux/LinuxSerial.cpp b/cores/ardulinux/linux/LinuxSerial.cpp index beed04b..b7ebd74 100644 --- a/cores/ardulinux/linux/LinuxSerial.cpp +++ b/cores/ardulinux/linux/LinuxSerial.cpp @@ -302,8 +302,18 @@ namespace arduino { return -1; } - /** No-op: stdout is unbuffered at this level. */ - void SimSerial::flush(void) {} + /** + * Push any buffered stdout to the underlying fd. + * + * stdout is line-buffered (see the constructor), so a completed log line + * leaves on its own newline. A partial line -- Serial.print() with no + * trailing newline, e.g. a prompt or a progress counter -- sits in the + * buffer until the next newline or exit. Arduino's flush() contract is + * "wait for outgoing data to be transmitted", so honour it here. + */ + void SimSerial::flush(void) { + fflush(stdout); + } /** * Write one byte to stdout. diff --git a/cores/ardulinux/linux/LinuxSerial.h b/cores/ardulinux/linux/LinuxSerial.h index 1f435a8..ccd9abb 100644 --- a/cores/ardulinux/linux/LinuxSerial.h +++ b/cores/ardulinux/linux/LinuxSerial.h @@ -132,7 +132,7 @@ namespace arduino { virtual int peek(void); /** Always returns -1. */ virtual int read(void); - /** No-op. */ + /** Push buffered stdout out now (flushes partial, newline-less lines). */ virtual void flush(void); /** * Write one byte to stdout. diff --git a/tests/unit/test_sim_serial.cpp b/tests/unit/test_sim_serial.cpp index 3f69adb..e1bc6cb 100644 --- a/tests/unit/test_sim_serial.cpp +++ b/tests/unit/test_sim_serial.cpp @@ -60,11 +60,6 @@ TEST_CASE("SimSerial::end is a no-op and does not crash", "[serial][sim]") { CHECK_NOTHROW(sim.end()); } -TEST_CASE("SimSerial::flush is a no-op and does not crash", "[serial][sim]") { - arduino::SimSerial sim; - CHECK_NOTHROW(sim.flush()); -} - // ─── Global Serial instance ─────────────────────────────────────────────────── TEST_CASE("arduino::Serial (global SimSerial) is always true", "[serial][sim]") { @@ -75,41 +70,80 @@ TEST_CASE("arduino::Serial::available returns 0", "[serial][sim]") { CHECK(arduino::Serial.available() == 0); } -// ─── stdout buffering (SimSerial constructor) ───────────────────────────────── +// ─── stdout buffering and flush ─────────────────────────────────────────────── // -// The SimSerial constructor line-buffers stdout once, at static init, so that -// Serial output reaches a non-TTY sink (pipe, file, journald) on each newline -// rather than sitting in stdio's full-buffer until exit. Asserted -// behaviourally: point fd 1 at a pipe, emit a line, and read it straight back. -// The read must happen before any fflush() — flushing by hand would push the -// line through even when stdout is fully buffered, and mask a regression. - -TEST_CASE("SimSerial constructor line-buffers stdout", "[serial][sim][buffering]") { - // Drain anything the harness left pending so the pipe sees only our line. +// SimSerial's constructor line-buffers stdout once, at static init, so Serial +// output reaches a non-TTY sink (pipe, file, journald) on each newline instead +// of sitting in stdio's full-buffer until exit. flush() covers the remainder: +// a partial, newline-less line has nothing to trigger the line flush. +// +// Both are asserted behaviourally, by pointing fd 1 at a pipe and reading back +// what actually arrived. + +namespace { + +/** + * Run @p emit with fd 1 pointed at a pipe; return the bytes that reached the + * pipe by the time @p emit returned. + * + * The read is non-blocking and happens before any fflush() of our own: a + * courtesy flush here would push the data through even when stdout is fully + * buffered, and mask exactly the regression these tests exist to catch. + * + * @param emit Callable that writes via arduino::Serial. + * @return What landed in the pipe; empty if stdout held onto it. + */ +template +std::string capture_stdout_fd(F emit) { + // Drain anything the harness left pending so the pipe sees only emit()'s bytes. fflush(stdout); int pipefd[2]; REQUIRE(pipe(pipefd) == 0); - // A fully-buffered stdout puts nothing in the pipe; the read has to report - // that rather than block forever waiting for a writer that never writes. + // Nothing may ever be written; the read has to report that rather than block. REQUIRE(fcntl(pipefd[0], F_SETFL, O_NONBLOCK) == 0); - int saved_stdout = dup(STDOUT_FILENO); - REQUIRE(saved_stdout != -1); + int saved = dup(STDOUT_FILENO); + REQUIRE(saved != -1); REQUIRE(dup2(pipefd[1], STDOUT_FILENO) != -1); - // The trailing newline is what triggers the flush under _IOLBF. - arduino::Serial.println("LINEBUF"); + emit(); - char buf[64] = {0}; + char buf[128] = {0}; ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); - // Restore fd 1 before asserting, so a failure still reports to the console. - dup2(saved_stdout, STDOUT_FILENO); - close(saved_stdout); + // Restore fd 1 before the caller asserts, so failures still reach the console. + dup2(saved, STDOUT_FILENO); + close(saved); close(pipefd[1]); close(pipefd[0]); - REQUIRE(n > 0); - CHECK(std::string(buf).find("LINEBUF") != std::string::npos); + return (n > 0) ? std::string(buf, static_cast(n)) : std::string(); +} + +} // namespace + +TEST_CASE("SimSerial constructor line-buffers stdout", "[serial][sim][buffering]") { + // The trailing newline is what triggers the flush under _IOLBF. + std::string out = capture_stdout_fd([] { arduino::Serial.println("LINEBUF"); }); + CHECK(out.find("LINEBUF") != std::string::npos); +} + +TEST_CASE("SimSerial holds a partial line until flushed", "[serial][sim][buffering]") { + // No newline, so line buffering has nothing to act on. This is the control + // for the flush test below: without it, that test would pass even if + // stdout were unbuffered and flush() still a no-op. + std::string out = capture_stdout_fd([] { arduino::Serial.print("HELD"); }); + CHECK(out.find("HELD") == std::string::npos); + + // Drop the held bytes on the real stdout rather than leaving them buffered. + arduino::Serial.flush(); +} + +TEST_CASE("SimSerial::flush pushes a partial line out", "[serial][sim][buffering]") { + std::string out = capture_stdout_fd([] { + arduino::Serial.print("PARTIAL"); + arduino::Serial.flush(); + }); + CHECK(out.find("PARTIAL") != std::string::npos); }