From 4a7f4bf872fd237db4fa4af86f5df1f68b332532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Rohl=C3=ADnek?= Date: Tue, 17 Mar 2026 19:38:01 +0100 Subject: [PATCH] feat(system/esp_stdio): Add test coverage for esp_stdio refactor --- components/esp_stdio/Kconfig | 8 +- components/esp_stdio/stdio_vfs.c | 2 +- .../test_apps/stdio/main/test_app_main.c | 115 +++++++++++++++++- .../test_apps/stdio/pytest_esp_stdio_tests.py | 104 +++++++++++++++- .../stdio/sdkconfig.ci.stdio_basic_mode | 1 + .../sdkconfig.ci.stdio_non_basic_small_fd | 1 + 6 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_basic_mode create mode 100644 components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_non_basic_small_fd diff --git a/components/esp_stdio/Kconfig b/components/esp_stdio/Kconfig index 996ba13ff34..a9b1da8c5e6 100644 --- a/components/esp_stdio/Kconfig +++ b/components/esp_stdio/Kconfig @@ -189,12 +189,16 @@ menu "ESP-STDIO" int "Max logical stdio file descriptors" depends on VFS_SUPPORT_IO range 0 64 - default 3 + default 16 help Maximum number of logical /dev/console descriptors tracked by esp_stdio VFS. - 0 enables basic mode with a shared internal descriptor. - 1..2 is not allowed - - Values >3 enable descriptor-table mode. + - Values >= 3 select descriptor-table mode (minimum table size is 3). + + stdin, stdout, and stderr each occupy one slot. The default leaves room for additional + opens of /dev/console (e.g. dup2, reopen). If set to exactly 3, only the three standard + streams fit and further opens fail with ENFILE unless one is closed first. Enabling basic mode decreases RAM usage, but disables checks for per-fd behaviour, such as read-only. diff --git a/components/esp_stdio/stdio_vfs.c b/components/esp_stdio/stdio_vfs.c index 06d96e1dbee..bf0351fde23 100644 --- a/components/esp_stdio/stdio_vfs.c +++ b/components/esp_stdio/stdio_vfs.c @@ -84,7 +84,7 @@ static const char *TAG = "esp_stdio"; static inline bool is_fd_valid(int fd) { #if ESP_STDIO_IS_BASIC - return (fd == 0) && (s_ctx.fd_count > 0); + return (fd >= 0) && (s_ctx.fd_count > 0); #else return (fd >= 0) && (fd < CONFIG_ESP_STDIO_MAX_FDS) && s_ctx.fds[fd].in_use; #endif diff --git a/components/esp_stdio/test_apps/stdio/main/test_app_main.c b/components/esp_stdio/test_apps/stdio/main/test_app_main.c index 8a8663867b6..42548f27566 100644 --- a/components/esp_stdio/test_apps/stdio/main/test_app_main.c +++ b/components/esp_stdio/test_apps/stdio/main/test_app_main.c @@ -1,11 +1,12 @@ /* - * SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD * * SPDX-License-Identifier: Apache-2.0 */ #include #include #include +#include #include "sdkconfig.h" #include "esp_rom_serial_output.h" @@ -50,6 +51,7 @@ static void console_none_print(void) #endif #if CONFIG_VFS_SUPPORT_IO +#if CONFIG_ESP_STDIO_MAX_FDS <= 0 || CONFIG_ESP_STDIO_MAX_FDS > 3 static void console_open_close_check(void) { printf("Opening /dev/console\n"); @@ -65,6 +67,110 @@ static void console_open_close_check(void) printf("This should be printed to stdout\n"); } +#endif + +static void stdio_fd_mode_behavior_check(void) +{ +#if CONFIG_ESP_STDIO_MAX_FDS <= 0 + printf("STDIO_TEST:MODE=BASIC\n"); + + int fd0 = open("/dev/console", O_RDWR); + int fd1 = open("/dev/console", O_RDWR); + int fd2 = open("/dev/console", O_RDWR); + assert(fd0 >= 0); + assert(fd1 >= 0); + assert(fd2 >= 0); + printf("STDIO_TEST:BASIC:OPEN_OK\n"); + + const char *msg = "STDIO_TEST:BASIC:WRITE_OK\n"; + ssize_t wr = write(fd0, msg, strlen(msg)); + assert(wr == (ssize_t) strlen(msg)); + + assert(close(fd0) == 0); + assert(close(fd1) == 0); + assert(close(fd2) == 0); + printf("STDIO_TEST:BASIC:CLOSE_OK\n"); + + errno = 0; + assert(close(-1) < 0); + assert(errno == EBADF); + + errno = 0; + assert(write(fd0, msg, strlen(msg)) < 0); + assert(errno == EBADF); + printf("STDIO_TEST:BASIC:EBADF_OK\n"); +#else + printf("STDIO_TEST:MODE=NON_BASIC\n"); + + errno = 0; + int fd0 = open("/dev/console", O_RDWR); + if (fd0 < 0) { + /* Small fd limits can be fully consumed by stdin/stdout/stderr. */ + assert(errno == ENFILE); + printf("STDIO_TEST:NON_BASIC:SATURATED_OK\n"); + return; + } + + int fd1 = open("/dev/console", O_RDWR); + int fd2 = open("/dev/console", O_RDWR); + assert(fd0 >= 0); + assert(fd1 >= 0); + assert(fd2 >= 0); + assert(fd0 != fd1 && fd1 != fd2 && fd0 != fd2); + printf("STDIO_TEST:NON_BASIC:UNIQUE_OK\n"); + + assert(close(fd1) == 0); + int fd_reused = open("/dev/console", O_RDWR); + assert(fd_reused == fd1); + printf("STDIO_TEST:NON_BASIC:REUSE_OK\n"); + assert(close(fd0) == 0); + assert(close(fd2) == 0); + assert(close(fd_reused) == 0); + + int fds[CONFIG_ESP_STDIO_MAX_FDS + 2]; + int opened = 0; + while (opened < (int)(sizeof(fds) / sizeof(fds[0]))) { + int fd = open("/dev/console", O_RDWR); + if (fd < 0) { + break; + } + fds[opened++] = fd; + } + assert(opened >= 0); + assert(opened <= CONFIG_ESP_STDIO_MAX_FDS); + errno = 0; + assert(open("/dev/console", O_RDWR) < 0); + assert(errno == ENFILE); + printf("STDIO_TEST:NON_BASIC:LIMIT_OK\n"); + + for (int i = 0; i < opened; ++i) { + assert(close(fds[i]) == 0); + } + + const char *msg = "x"; + errno = 0; + assert(write(-1, msg, 1) < 0); + assert(errno == EBADF); + printf("STDIO_TEST:NON_BASIC:EBADF_OK\n"); +#endif +} + +static void stdio_fd_init_check(void) +{ + int in_fd = fileno(stdin); + int out_fd = fileno(stdout); + int err_fd = fileno(stderr); + + assert(in_fd == 0); + assert(out_fd == 1); + assert(err_fd == 2); + + printf("STDIO_TEST:FD_INIT:IN=%d,OUT=%d,ERR=%d\n", in_fd, out_fd, err_fd); + fprintf(stdout, "STDIO_TEST:STREAM:STDOUT\n"); + fflush(stdout); + fprintf(stderr, "STDIO_TEST:STREAM:STDERR\n"); + fflush(stderr); +} #endif // CONFIG_VFS_SUPPORT_IO void app_main(void) @@ -80,6 +186,8 @@ void app_main(void) assert(fd > 2 && "Incorrect file descriptor returned, stdin, stdout, stderr were not correctly assigned"); close(fd); + + stdio_fd_init_check(); #endif // CONFIG_VFS_SUPPORT_IO #if CONFIG_ESP_CONSOLE_NONE @@ -87,7 +195,12 @@ void app_main(void) #endif // CONFIG_ESP_CONSOLE_NONE #if CONFIG_VFS_SUPPORT_IO +#if CONFIG_ESP_STDIO_MAX_FDS <= 0 || CONFIG_ESP_STDIO_MAX_FDS > 3 console_open_close_check(); +#else + printf("STDIO_TEST:SKIP:OPEN_CLOSE_CHECK\n"); +#endif + stdio_fd_mode_behavior_check(); #endif // CONFIG_VFS_SUPPORT_IO } diff --git a/components/esp_stdio/test_apps/stdio/pytest_esp_stdio_tests.py b/components/esp_stdio/test_apps/stdio/pytest_esp_stdio_tests.py index 9093e3cb0cf..aad8aa7b080 100644 --- a/components/esp_stdio/test_apps/stdio/pytest_esp_stdio_tests.py +++ b/components/esp_stdio/test_apps/stdio/pytest_esp_stdio_tests.py @@ -1,5 +1,9 @@ -# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD +# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: CC0-1.0 +# +# HW runs: use --embedded-services esp,idf (not idf alone) so serial is attached. +# See README.md in this directory for QEMU vs hardware commands and common failures. +# import pexpect import pytest from pytest_embedded import Dut @@ -7,6 +11,11 @@ from pytest_embedded_idf.utils import idf_parametrize from pytest_embedded_idf.utils import soc_filtered_targets +def _expect_app_main_banner(dut: Dut) -> None: + """Sync to the latest app boot (avoids matching a stale 'Hello World' left in the serial buffer).""" + dut.expect('Calling app_main()') + + def not_expect(dut: Dut, output_regex: str) -> None: try: dut.expect(output_regex, timeout=5) @@ -33,7 +42,11 @@ def test_esp_system_stdio_no_output_uart(dut: Dut) -> None: @pytest.mark.temp_skip_ci(targets=['esp32p4'], reason='p4 rev3 migration') def test_esp_system_stdio_only_serial_jtag(dut: Dut) -> None: dut.expect('2nd stage bootloader') + _expect_app_main_banner(dut) dut.expect('Hello World') + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:STREAM:STDOUT') + dut.expect('STDIO_TEST:STREAM:STDERR') dut.expect('Opening /dev/console') dut.expect('This should be printed to stdout') dut.expect('Closing /dev/console') @@ -48,6 +61,7 @@ def test_esp_system_stdio_only_serial_jtag(dut: Dut) -> None: @pytest.mark.temp_skip_ci(targets=['esp32p4'], reason='p4 rev3 migration') def test_esp_system_stdio_only_serial_jtag_no_vfs(dut: Dut) -> None: dut.expect('2nd stage bootloader') + _expect_app_main_banner(dut) dut.expect('Hello World') @@ -59,6 +73,7 @@ def test_esp_system_stdio_only_serial_jtag_no_vfs(dut: Dut) -> None: @pytest.mark.temp_skip_ci(targets=['esp32p4'], reason='p4 rev3 migration') def test_esp_system_stdio_secondary_serial_jtag(dut: Dut) -> None: dut.expect('2nd stage bootloader') + _expect_app_main_banner(dut) dut.expect('Hello World') @@ -68,8 +83,95 @@ def test_esp_system_stdio_secondary_serial_jtag(dut: Dut) -> None: @pytest.mark.temp_skip_ci(targets=['esp32p4'], reason='p4 rev3 migration') def test_esp_system_stdio_correct_open_and_close(dut: Dut) -> None: dut.expect('2nd stage bootloader') + _expect_app_main_banner(dut) dut.expect('Hello World') + # Order matches test_app_main.c: stdio_fd_init_check() runs before console_open_close_check() + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:STREAM:STDOUT') + dut.expect('STDIO_TEST:STREAM:STDERR') dut.expect('Opening /dev/console') dut.expect('This should be printed to stdout') dut.expect('Closing /dev/console') dut.expect('This should be printed to stdout') + dut.expect('STDIO_TEST:MODE=NON_BASIC') + dut.expect('STDIO_TEST:NON_BASIC:UNIQUE_OK') + dut.expect('STDIO_TEST:NON_BASIC:REUSE_OK') + dut.expect('STDIO_TEST:NON_BASIC:LIMIT_OK') + dut.expect('STDIO_TEST:NON_BASIC:EBADF_OK') + + +@pytest.mark.host_test +@pytest.mark.qemu +@pytest.mark.parametrize( + 'config, embedded_services', + [('simple', 'idf,qemu')], +) +@idf_parametrize('target', ['esp32'], indirect=['target']) +def test_esp_stdio_non_basic_default_qemu(dut: Dut) -> None: + dut.expect('Hello World') + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:MODE=NON_BASIC') + dut.expect('STDIO_TEST:NON_BASIC:UNIQUE_OK') + dut.expect('STDIO_TEST:NON_BASIC:REUSE_OK') + dut.expect('STDIO_TEST:NON_BASIC:LIMIT_OK') + dut.expect('STDIO_TEST:NON_BASIC:EBADF_OK') + + +@pytest.mark.host_test +@pytest.mark.qemu +@pytest.mark.parametrize( + 'config, embedded_services', + [('stdio_non_basic_small_fd', 'idf,qemu')], +) +@idf_parametrize('target', ['esp32'], indirect=['target']) +def test_esp_stdio_non_basic_small_fd_mode_qemu(dut: Dut) -> None: + dut.expect('Hello World') + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:MODE=NON_BASIC') + dut.expect('STDIO_TEST:NON_BASIC:SATURATED_OK') + + +@pytest.mark.host_test +@pytest.mark.qemu +@pytest.mark.parametrize( + 'config, embedded_services', + [('stdio_basic_mode', 'idf,qemu')], +) +@idf_parametrize('target', ['esp32'], indirect=['target']) +def test_esp_stdio_basic_mode_qemu(dut: Dut) -> None: + dut.expect('Hello World') + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:MODE=BASIC') + dut.expect('STDIO_TEST:BASIC:OPEN_OK') + dut.expect('STDIO_TEST:BASIC:WRITE_OK') + dut.expect('STDIO_TEST:BASIC:CLOSE_OK') + dut.expect('STDIO_TEST:BASIC:EBADF_OK') + + +@pytest.mark.generic +@idf_parametrize('config', ['stdio_non_basic_small_fd'], indirect=['config']) +@idf_parametrize('target', ['supported_targets'], indirect=['target']) +@pytest.mark.temp_skip_ci(targets=['esp32p4'], reason='p4 rev3 migration') +def test_esp_stdio_non_basic_small_fd_mode(dut: Dut) -> None: + dut.expect('2nd stage bootloader') + _expect_app_main_banner(dut) + dut.expect('Hello World') + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:MODE=NON_BASIC') + dut.expect('STDIO_TEST:NON_BASIC:SATURATED_OK') + + +@pytest.mark.generic +@idf_parametrize('config', ['stdio_basic_mode'], indirect=['config']) +@idf_parametrize('target', ['supported_targets'], indirect=['target']) +@pytest.mark.temp_skip_ci(targets=['esp32p4'], reason='p4 rev3 migration') +def test_esp_stdio_basic_mode(dut: Dut) -> None: + dut.expect('2nd stage bootloader') + _expect_app_main_banner(dut) + dut.expect('Hello World') + dut.expect('STDIO_TEST:FD_INIT:IN=0,OUT=1,ERR=2') + dut.expect('STDIO_TEST:MODE=BASIC') + dut.expect('STDIO_TEST:BASIC:OPEN_OK') + dut.expect('STDIO_TEST:BASIC:WRITE_OK') + dut.expect('STDIO_TEST:BASIC:CLOSE_OK') + dut.expect('STDIO_TEST:BASIC:EBADF_OK') diff --git a/components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_basic_mode b/components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_basic_mode new file mode 100644 index 00000000000..9f404e13d40 --- /dev/null +++ b/components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_basic_mode @@ -0,0 +1 @@ +CONFIG_ESP_STDIO_MAX_FDS=0 diff --git a/components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_non_basic_small_fd b/components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_non_basic_small_fd new file mode 100644 index 00000000000..d077b890e82 --- /dev/null +++ b/components/esp_stdio/test_apps/stdio/sdkconfig.ci.stdio_non_basic_small_fd @@ -0,0 +1 @@ +CONFIG_ESP_STDIO_MAX_FDS=3