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/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"], diff --git a/tests/unit/test_sim_serial.cpp b/tests/unit/test_sim_serial.cpp index ae59868..e1bc6cb 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 ─────────────────────────────────────────────────── @@ -56,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]") { @@ -70,3 +69,81 @@ 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 and flush ─────────────────────────────────────────────── +// +// 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); + // 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 = dup(STDOUT_FILENO); + REQUIRE(saved != -1); + REQUIRE(dup2(pipefd[1], STDOUT_FILENO) != -1); + + emit(); + + char buf[128] = {0}; + ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); + + // 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]); + + 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); +}