From 93dd70389596cc428298bab07115b0dda7f95b49 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 01:12:36 +0200 Subject: [PATCH 1/9] Increase app partitions P4 devices are reaching the 4MB app partition limit. This will give them more space. --- partitions-16mb-no-sd-dev.csv | 2 +- partitions-16mb-no-sd.csv | 4 ++-- partitions-16mb-with-sd.csv | 2 +- partitions-32mb-no-sd-dev.csv | 2 +- partitions-32mb-no-sd.csv | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/partitions-16mb-no-sd-dev.csv b/partitions-16mb-no-sd-dev.csv index 2a508da19..503609ee2 100644 --- a/partitions-16mb-no-sd-dev.csv +++ b/partitions-16mb-no-sd-dev.csv @@ -2,6 +2,6 @@ # Offsets are left blank so the build auto-places partitions relative to CONFIG_PARTITION_TABLE_OFFSET (see device.py), including on targets where that offset is bumped to fit a larger bootloader. nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, -factory, app, factory, , 4M, +factory, app, factory, , 6M, system, data, fat, , 128k, data, data, fat, , 1000k, diff --git a/partitions-16mb-no-sd.csv b/partitions-16mb-no-sd.csv index dd442eed9..761e9f011 100644 --- a/partitions-16mb-no-sd.csv +++ b/partitions-16mb-no-sd.csv @@ -2,6 +2,6 @@ # Offsets are left blank so the build auto-places partitions relative to CONFIG_PARTITION_TABLE_OFFSET (see device.py), including on targets where that offset is bumped to fit a larger bootloader. nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, -factory, app, factory, , 4M, +factory, app, factory, , 6M, system, data, fat, , 128k, -data, data, fat, , 11800k, +data, data, fat, , 9800k, diff --git a/partitions-16mb-with-sd.csv b/partitions-16mb-with-sd.csv index f5795298c..5da41e2b3 100644 --- a/partitions-16mb-with-sd.csv +++ b/partitions-16mb-with-sd.csv @@ -2,5 +2,5 @@ # Offsets are left blank so the build auto-places partitions relative to CONFIG_PARTITION_TABLE_OFFSET (see device.py), including on targets where that offset is bumped to fit a larger bootloader. nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, -factory, app, factory, , 4M, +factory, app, factory, , 6M, system, data, fat, , 128k, diff --git a/partitions-32mb-no-sd-dev.csv b/partitions-32mb-no-sd-dev.csv index 2a508da19..503609ee2 100644 --- a/partitions-32mb-no-sd-dev.csv +++ b/partitions-32mb-no-sd-dev.csv @@ -2,6 +2,6 @@ # Offsets are left blank so the build auto-places partitions relative to CONFIG_PARTITION_TABLE_OFFSET (see device.py), including on targets where that offset is bumped to fit a larger bootloader. nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, -factory, app, factory, , 4M, +factory, app, factory, , 6M, system, data, fat, , 128k, data, data, fat, , 1000k, diff --git a/partitions-32mb-no-sd.csv b/partitions-32mb-no-sd.csv index 3c0b8b6a7..70a6feb2a 100644 --- a/partitions-32mb-no-sd.csv +++ b/partitions-32mb-no-sd.csv @@ -2,6 +2,6 @@ # Offsets are left blank so the build auto-places partitions relative to CONFIG_PARTITION_TABLE_OFFSET (see device.py), including on targets where that offset is bumped to fit a larger bootloader. nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, -factory, app, factory, , 4M, +factory, app, factory, , 6M, system, data, fat, , 128k, -data, data, fat, , 28480k, +data, data, fat, , 26480k, From 75732e124c9be356e363fb320ca30bf34bea890c Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 12:14:28 +0200 Subject: [PATCH 2/9] Improve terminal support and TactilitySDK --- .github/actions/build-sdk-posix/action.yml | 2 +- .github/actions/build-sdk/action.yml | 2 +- Buildscripts/CDN/upload-sdk-files.py | 25 + Buildscripts/TactilitySDK/sdkconfig.app.esp32 | 24 + .../TactilitySDK/sdkconfig.app.esp32c6 | 24 + .../TactilitySDK/sdkconfig.app.esp32p4 | 24 + .../TactilitySDK/sdkconfig.app.esp32s3 | 24 + Buildscripts/release-sdk-esp32.py | 19 + CMakeLists.txt | 9 +- Documentation/ideas.md | 1 + Modules/app-module/CMakeLists.txt | 2 +- Modules/app-module/include/app/io.h | 8 + .../private/app/private/stdio_wrap.h | 43 ++ .../private/app/private/stdio_wrap_posix.h | 39 ++ Modules/app-module/source/io.cpp | 16 + Modules/app-module/source/module.cpp | 1 + Modules/app-module/source/stdio_wrap.cpp | 628 +++--------------- .../app-module/source/stdio_wrap_apple.cpp | 37 ++ Modules/app-module/source/stdio_wrap_elf.cpp | 112 ++++ .../app-module/source/stdio_wrap_esp32.cpp | 116 ++++ .../app-module/source/stdio_wrap_posix.cpp | 376 +++++++++++ Modules/app-module/tests/CMakeLists.txt | 2 +- Modules/app-module/tests/source/io_test.cpp | 68 ++ Modules/app-posix-module/tests/CMakeLists.txt | 2 +- Modules/c-symbols-module/source/module.cpp | 6 + .../posix-symbols-module/source/module.cpp | 15 + Tactility/Source/app/shell/LineEditor.cpp | 2 +- Tactility/Source/app/terminal/vterm/vterm.c | 17 +- device.py | 2 + .../tactility.py => tactility.py | 84 +-- tactility.py.json | 3 + 31 files changed, 1124 insertions(+), 609 deletions(-) create mode 100644 Buildscripts/TactilitySDK/sdkconfig.app.esp32 create mode 100644 Buildscripts/TactilitySDK/sdkconfig.app.esp32c6 create mode 100644 Buildscripts/TactilitySDK/sdkconfig.app.esp32p4 create mode 100644 Buildscripts/TactilitySDK/sdkconfig.app.esp32s3 create mode 100644 Modules/app-module/private/app/private/stdio_wrap.h create mode 100644 Modules/app-module/private/app/private/stdio_wrap_posix.h create mode 100644 Modules/app-module/source/stdio_wrap_apple.cpp create mode 100644 Modules/app-module/source/stdio_wrap_elf.cpp create mode 100644 Modules/app-module/source/stdio_wrap_esp32.cpp create mode 100644 Modules/app-module/source/stdio_wrap_posix.cpp rename Tests/SdkIntegration/tactility.py => tactility.py (93%) create mode 100644 tactility.py.json diff --git a/.github/actions/build-sdk-posix/action.yml b/.github/actions/build-sdk-posix/action.yml index 5bab1cc2b..abbc32465 100644 --- a/.github/actions/build-sdk-posix/action.yml +++ b/.github/actions/build-sdk-posix/action.yml @@ -32,7 +32,7 @@ runs: shell: bash env: TACTILITY_ARCH: ${{ steps.arch.outputs.value }} - run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python tactility.py build -a posix-$TACTILITY_ARCH --local-sdk + run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python ../../tactility.py build -a posix-$TACTILITY_ARCH --local-sdk - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index dfcaa5f16..e84375634 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -43,7 +43,7 @@ runs: with: esp_idf_version: v6.1 target: ${{ inputs.arch }} - command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python tactility.py build -a ${{ inputs.arch }} --local-sdk + command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python ../../tactility.py build -a ${{ inputs.arch }} --local-sdk - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/Buildscripts/CDN/upload-sdk-files.py b/Buildscripts/CDN/upload-sdk-files.py index 7a8b642f5..72dfd96a6 100644 --- a/Buildscripts/CDN/upload-sdk-files.py +++ b/Buildscripts/CDN/upload-sdk-files.py @@ -1,4 +1,7 @@ +import io +import json import os +import re import sys import boto3 @@ -22,6 +25,15 @@ def exit_with_error(message): print_error(message) sys.exit(1) +TOOL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tactility.py") + +def create_tool_json(): + with open(TOOL_PATH, "r") as file: + match = re.search(r'^ttbuild_version = "([^"]+)"', file.read(), re.MULTILINE) + if match is None: + exit_with_error(f"ttbuild_version not found in {TOOL_PATH}") + return json.dumps({"toolVersion": match.group(1)}, indent=2).encode("utf-8") + def main(path: str, version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool): if not os.path.exists(path): exit_with_error(f"Path not found: {path}") @@ -45,6 +57,19 @@ def main(path: str, version: str, cloudflare_account_id, cloudflare_token_name: except Exception as e: exit_with_error(f"Failed to upload {file_name}: {str(e)}") counter += 1 + if not index_only: + tool_object_path = f"sdk/{version}/tactility.py" + print(f"Uploading tactility.py to {tool_object_path}") + try: + s3.upload_file(TOOL_PATH, "tactility", tool_object_path) + except Exception as e: + exit_with_error(f"Failed to upload tactility.py: {str(e)}") + tool_json_object_path = f"sdk/{version}/tactility.py.json" + print(f"Uploading tactility.py.json to {tool_json_object_path}") + try: + s3.upload_fileobj(io.BytesIO(create_tool_json()), "tactility", tool_json_object_path) + except Exception as e: + exit_with_error(f"Failed to upload tactility.py.json: {str(e)}") if __name__ == "__main__": print("Tactility CDN SDK Uploader") diff --git a/Buildscripts/TactilitySDK/sdkconfig.app.esp32 b/Buildscripts/TactilitySDK/sdkconfig.app.esp32 new file mode 100644 index 000000000..e3b98992e --- /dev/null +++ b/Buildscripts/TactilitySDK/sdkconfig.app.esp32 @@ -0,0 +1,24 @@ +# FreeRTOS defaults (must match wit OS) +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 +CONFIG_FREERTOS_SMP=n +CONFIG_FREERTOS_UNICORE=n +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096 +CONFIG_FREERTOS_USE_TRACE_FACILITY=y + +# FatFS defaults (must match with OS) +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FATFS_VOLUME_COUNT=3 + +# App-specific +CONFIG_PARTITION_TABLE_SINGLE_APP=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_LOCK=n + +# Platform +CONFIG_IDF_TARGET="esp32" + +# Force newlib for IDF 6.x and higher +CONFIG_LIBC_NEWLIB=y + +# VFS (must match with OS): exposes termios.h and tcgetattr()/tcsetattr() +CONFIG_VFS_SUPPORT_TERMIOS=y diff --git a/Buildscripts/TactilitySDK/sdkconfig.app.esp32c6 b/Buildscripts/TactilitySDK/sdkconfig.app.esp32c6 new file mode 100644 index 000000000..d3e1975a9 --- /dev/null +++ b/Buildscripts/TactilitySDK/sdkconfig.app.esp32c6 @@ -0,0 +1,24 @@ +# FreeRTOS defaults (must match wit OS) +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 +CONFIG_FREERTOS_SMP=n +CONFIG_FREERTOS_UNICORE=n +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096 +CONFIG_FREERTOS_USE_TRACE_FACILITY=y + +# FatFS defaults (must match with OS) +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FATFS_VOLUME_COUNT=3 + +# App-specific +CONFIG_PARTITION_TABLE_SINGLE_APP=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_LOCK=n + +# Platform +CONFIG_IDF_TARGET="esp32c6" + +# Force newlib for IDF 6.x and higher +CONFIG_LIBC_NEWLIB=y + +# VFS (must match with OS): exposes termios.h and tcgetattr()/tcsetattr() +CONFIG_VFS_SUPPORT_TERMIOS=y diff --git a/Buildscripts/TactilitySDK/sdkconfig.app.esp32p4 b/Buildscripts/TactilitySDK/sdkconfig.app.esp32p4 new file mode 100644 index 000000000..d2f3c2872 --- /dev/null +++ b/Buildscripts/TactilitySDK/sdkconfig.app.esp32p4 @@ -0,0 +1,24 @@ +# FreeRTOS defaults (must match wit OS) +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 +CONFIG_FREERTOS_SMP=n +CONFIG_FREERTOS_UNICORE=n +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096 +CONFIG_FREERTOS_USE_TRACE_FACILITY=y + +# FatFS defaults (must match with OS) +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FATFS_VOLUME_COUNT=3 + +# App-specific +CONFIG_PARTITION_TABLE_SINGLE_APP=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_LOCK=n + +# Platform +CONFIG_IDF_TARGET="esp32p4" + +# Force newlib for IDF 6.x and higher +CONFIG_LIBC_NEWLIB=y + +# VFS (must match with OS): exposes termios.h and tcgetattr()/tcsetattr() +CONFIG_VFS_SUPPORT_TERMIOS=y diff --git a/Buildscripts/TactilitySDK/sdkconfig.app.esp32s3 b/Buildscripts/TactilitySDK/sdkconfig.app.esp32s3 new file mode 100644 index 000000000..d0e07f23e --- /dev/null +++ b/Buildscripts/TactilitySDK/sdkconfig.app.esp32s3 @@ -0,0 +1,24 @@ +# FreeRTOS defaults (must match wit OS) +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 +CONFIG_FREERTOS_SMP=n +CONFIG_FREERTOS_UNICORE=n +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096 +CONFIG_FREERTOS_USE_TRACE_FACILITY=y + +# FatFS defaults (must match with OS) +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FATFS_VOLUME_COUNT=3 + +# App-specific +CONFIG_PARTITION_TABLE_SINGLE_APP=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_LOCK=n + +# Platform +CONFIG_IDF_TARGET="esp32s3" + +# Force newlib for IDF 6.x and higher +CONFIG_LIBC_NEWLIB=y + +# VFS (must match with OS): exposes termios.h and tcgetattr()/tcsetattr() +CONFIG_VFS_SUPPORT_TERMIOS=y diff --git a/Buildscripts/release-sdk-esp32.py b/Buildscripts/release-sdk-esp32.py index 7acbc3aba..873316578 100644 --- a/Buildscripts/release-sdk-esp32.py +++ b/Buildscripts/release-sdk-esp32.py @@ -2,6 +2,7 @@ import os import glob +import shutil import subprocess import sys import importlib.util @@ -57,6 +58,23 @@ def add_module(target_path, module_name): cmakelists_content = create_module_cmakelists(module_name) shared.write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content) +def get_idf_target(): + with open("sdkconfig", "r") as f: + for line in f: + if line.startswith("CONFIG_IDF_TARGET="): + return line.split('=')[1].strip().strip('"') + print("Error: CONFIG_IDF_TARGET not found in sdkconfig") + sys.exit(1) + +def add_app_sdkconfig(target_path): + """Bundles the sdkconfig that apps must build with for this SDK's target.""" + filename = f"sdkconfig.app.{get_idf_target()}" + src = os.path.join('Buildscripts', 'TactilitySDK', filename) + if not os.path.isfile(src): + print(f"Error: {src} does not exist") + sys.exit(1) + shutil.copy2(src, os.path.join(target_path, filename)) + def main(): if len(sys.argv) < 2: print("Usage: release-sdk-esp32.py [target_path]") @@ -117,6 +135,7 @@ def main(): # Final scripts - copied verbatim shared.generate_tactility_sdk_cmake(target_path, 'esp32') shared.generate_tactility_sdk_top_cmakelists(target_path) + add_app_sdkconfig(target_path) # Output ESP-IDF SDK version to file with open(os.path.join(target_path, "idf-version.txt"), "w") as f: diff --git a/CMakeLists.txt b/CMakeLists.txt index de4ec1a12..c79216f64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,7 +63,7 @@ if (DEFINED ENV{ESP_IDF_VERSION}) # Wraps newlib's reentrant syscall stubs, not the plain read()/write()/close() newlib itself. # Implements as thin wrappers around them. newlib's own stdio (fflush()'s buffer-flush path in particular) # calls these _r stubs directly, bypassing the plain names entirely. - # See Modules/app-module/source/stdio_wrap.cpp's own comment for the exact call chain. + # See Modules/app-module/source/stdio_wrap_esp32.cpp's own comment for the exact call chain. idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_read_r" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_write_r" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_close_r" APPEND) @@ -75,6 +75,13 @@ if (DEFINED ENV{ESP_IDF_VERSION}) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=getcwd" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=chdir" APPEND) + # fstat()/isatty() go through _fstat_r. poll() and tcgetattr()/tcsetattr() are defined directly (poll.c, vfs_calls.c). + # Wrapped so app fds report as polling-capable raw character devices. + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_fstat_r" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=poll" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=tcgetattr" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=tcsetattr" APPEND) + # opendir()/readdir()/closedir() also define the plain names directly (vfs_calls.c). # Wrapped so opendir("/") synthesizes a listing of own registered filesystems instead of ENOENT. # See Platforms/platform-esp32/source/root_dir.cpp. diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 9add85f69..75c6f3991 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -22,6 +22,7 @@ ## Medium Priority +- Implement signal/raise for terminal apps (for CTRL+C and window resize signals) - netutils-module: ping, wget, ifconfig, nslookup, etc. - Core2: support power off via software - Improve Setup: Show "Step done" screen diff --git a/Modules/app-module/CMakeLists.txt b/Modules/app-module/CMakeLists.txt index 58f49d1be..3f7f3a43a 100644 --- a/Modules/app-module/CMakeLists.txt +++ b/Modules/app-module/CMakeLists.txt @@ -15,7 +15,7 @@ tactility_add_module(app-module # Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close() rather # than calling ::read/::write/::close() directly, on every platform where those are wrapped: via -# -Wl,--wrap= on ESP-IDF (see the top-level CMakeLists.txt), or via source/stdio_wrap.cpp's own +# -Wl,--wrap= on ESP-IDF (see the top-level CMakeLists.txt), or via source/stdio_wrap_posix.cpp's own # read/write/close and printf-family definitions on POSIX (dyld interpose on Apple, plain # symbol overrides elsewhere - see that file for why POSIX doesn't use --wrap). tactility_get_module_name(app-module MODULE_NAME) diff --git a/Modules/app-module/include/app/io.h b/Modules/app-module/include/app/io.h index ad43fa147..d6a7d4324 100644 --- a/Modules/app-module/include/app/io.h +++ b/Modules/app-module/include/app/io.h @@ -35,6 +35,14 @@ int app_io_close(int fd); */ error_t app_io_await(int fd, AppFileWait wait, TickType_t timeout); +/** + * FD-table dispatch for AppFileOps::poll(): reports readiness without blocking. + * @param[out] out_bits APP_FILE_READABLE and/or APP_FILE_WRITABLE + * @retval ERROR_NOT_FOUND @a fd isn't bound in the calling task's own app instance fd table + * @retval ERROR_NONE on success + */ +error_t app_io_poll(int fd, uint32_t* out_bits); + /** * FD-table dispatch for AppFileOps::ioctl(). * @retval ERROR_NOT_FOUND @a fd isn't bound in the calling task's own app instance fd table diff --git a/Modules/app-module/private/app/private/stdio_wrap.h b/Modules/app-module/private/app/private/stdio_wrap.h new file mode 100644 index 000000000..fdc5bb59d --- /dev/null +++ b/Modules/app-module/private/app/private/stdio_wrap.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// Neither platform's headers declare TIOCGWINSZ/struct winsize (ESP-IDF's sys/ioctl.h has no +// terminal ioctls at all; the guard is only for POSIX, where already provides them). +#ifndef TIOCGWINSZ +#define TIOCGWINSZ 0x5413 +struct winsize { + unsigned short ws_row; + unsigned short ws_col; + unsigned short ws_xpixel; + unsigned short ws_ypixel; +}; +#endif + +using PollFunction = int (*)(struct pollfd*, nfds_t, int); + +/** @return true (with *out filled) if this was a window-size query on an app-owned fd */ +bool tryAppWindowSize(int fd, unsigned long request, void* arg, struct winsize* out); + +/** @return false if the caller isn't an app instance and should fall through to the real getcwd() */ +bool tryAppGetCwd(char* buf, size_t size, char** out, int* outErrno); + +/** @return false if the caller isn't an app instance and should fall through to the real chdir() */ +bool tryAppChdir(const char* path, int* outResult, int* outErrno); + +bool isAppFd(int fd); + +/** @return false if @a fd isn't app-bound and the caller should fall through to the real fstat() */ +bool tryAppFstat(int fd, struct stat* st); + +void fillAppTermios(struct termios* t); + +/** @return false if no fd in @a fds is app-bound and the caller should fall through to @a realPoll */ +bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realPoll, int* outResult); diff --git a/Modules/app-module/private/app/private/stdio_wrap_posix.h b/Modules/app-module/private/app/private/stdio_wrap_posix.h new file mode 100644 index 000000000..45afb911b --- /dev/null +++ b/Modules/app-module/private/app/private/stdio_wrap_posix.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + + +// Implemented in stdio_wrap_posix.cpp, installed under the real names by stdio_wrap_elf.cpp or stdio_wrap_apple.cpp. +extern "C" { +ssize_t __wrap_read(int fd, void* buffer, size_t size); +ssize_t __wrap_write(int fd, const void* buffer, size_t size); +int __wrap_close(int fd); +int __wrap_ioctl(int fd, unsigned long request, ...); +char* __wrap_getcwd(char* buf, size_t size); +int __wrap_chdir(const char* path); +int __wrap_fstat(int fd, struct stat* st); +int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout); +int __wrap_tcgetattr(int fd, struct termios* p); +int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p); + +int __wrap_vprintf(const char* format, va_list args); +int __wrap_printf(const char* format, ...); +int __wrap_vfprintf(FILE* stream, const char* format, va_list args); +int __wrap_fprintf(FILE* stream, const char* format, ...); +int __wrap_puts(const char* s); +int __wrap_fputs(const char* s, FILE* stream); +int __wrap_putchar(int c); +int __wrap_fputc(int c, FILE* stream); +size_t __wrap_fwrite(const void* data, size_t size, size_t count, FILE* stream); +int __wrap_getchar(); +int __wrap_fgetc(FILE* stream); +char* __wrap_fgets(char* buffer, int size, FILE* stream); +} + diff --git a/Modules/app-module/source/io.cpp b/Modules/app-module/source/io.cpp index a66997ca4..a00515fcf 100644 --- a/Modules/app-module/source/io.cpp +++ b/Modules/app-module/source/io.cpp @@ -147,6 +147,22 @@ error_t app_io_await(int fd, AppFileWait wait, TickType_t timeout) { return result; } +error_t app_io_poll(int fd, uint32_t* out_bits) { + AppFdTable* table = current_app_fd_table(); + if (table == nullptr) { + return ERROR_NOT_FOUND; + } + AppFile file {}; + if (!app_fd_table_get_and_retain(table, fd, &file)) { + return ERROR_NOT_FOUND; + } + *out_bits = file.ops->poll(file.object); + if (file.ops->release != nullptr) { + file.ops->release(file.object); + } + return ERROR_NONE; +} + error_t app_io_ioctl(int fd, AppIoctlRequest request, void* arg) { AppFdTable* table = current_app_fd_table(); if (table == nullptr) { diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp index a8d8c80d3..25596b8f5 100644 --- a/Modules/app-module/source/module.cpp +++ b/Modules/app-module/source/module.cpp @@ -53,6 +53,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(app_io_write), DEFINE_MODULE_SYMBOL(app_io_close), DEFINE_MODULE_SYMBOL(app_io_await), + DEFINE_MODULE_SYMBOL(app_io_poll), DEFINE_MODULE_SYMBOL(app_io_ioctl), DEFINE_MODULE_SYMBOL(app_io_bind_self), // app/manager diff --git a/Modules/app-module/source/stdio_wrap.cpp b/Modules/app-module/source/stdio_wrap.cpp index 5c06281da..e8049f1cc 100644 --- a/Modules/app-module/source/stdio_wrap.cpp +++ b/Modules/app-module/source/stdio_wrap.cpp @@ -1,36 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -// ESP32 uses -Wl,--wrap=. POSIX can't: --wrap doesn't reach a dlopen()ed app's own printf/write -// calls, so these are defined under their real names instead - dyld interpose on Apple, plain -// strong definitions elsewhere (ELF gives the main executable's symbols priority process-wide). +// Platform-independent helpers for the syscall wraps in stdio_wrap_esp32.cpp and stdio_wrap_posix.cpp. +#include + #include #include +#include #include +#include #include -#include -#include #include -#include -#include - -// Neither platform's headers declare TIOCGWINSZ/struct winsize (ESP-IDF's sys/ioctl.h has no -// terminal ioctls at all; the guard is only for POSIX, where already provides them). -#ifndef TIOCGWINSZ -#define TIOCGWINSZ 0x5413 -struct winsize { - unsigned short ws_row; - unsigned short ws_col; - unsigned short ws_xpixel; - unsigned short ws_ypixel; -}; -#endif - -namespace { -// Shared by both platforms' ioctl wraps: true (with *out filled) if this was a window-size query -// on an app-owned fd, so the caller can skip falling through to the real syscall. bool tryAppWindowSize(int fd, unsigned long request, void* arg, struct winsize* out) { if (request != TIOCGWINSZ || arg == nullptr) { return false; @@ -99,549 +81,103 @@ bool tryAppChdir(const char* path, int* outResult, int* outErrno) { return true; } -} // namespace - -#ifdef ESP_PLATFORM - -// Newlib's own stdio calls the reentrant _read_r/_write_r/_close_r stubs directly, not the plain -// read/write/close wrappers, so those stubs are wrapped instead of the plain names. ioctl() has no -// such stub (esp_libc's vfs_calls.c defines the plain name directly), so it is wrapped as-is below. -#include - -extern "C" { - -ssize_t __wrap__read_r(struct _reent* r, int fd, void* buffer, size_t size) { - (void)r; - return app_io_read(fd, buffer, size); +bool isAppFd(int fd) { + uint32_t bits; + return app_io_poll(fd, &bits) == ERROR_NONE; } -ssize_t __wrap__write_r(struct _reent* r, int fd, const void* buffer, size_t size) { - (void)r; - return app_io_write(fd, buffer, size); +// App fds report as character devices, which also makes isatty() true for them. +bool tryAppFstat(int fd, struct stat* st) { + if (st == nullptr || !isAppFd(fd)) { + return false; + } + memset(st, 0, sizeof(*st)); + st->st_mode = S_IFCHR | 0666; + return true; } -int __wrap__close_r(struct _reent* r, int fd) { - (void)r; - return app_io_close(fd); +// App fd input is always raw and unechoed, and a written '\n' also returns the cursor. +// tcsetattr() accepts any settings without applying them. +void fillAppTermios(struct termios* t) { + memset(t, 0, sizeof(*t)); + t->c_oflag = OPOST | ONLCR; + t->c_cflag = CS8 | CREAD; + t->c_cc[VMIN] = 1; + t->c_cc[VTIME] = 0; } -int __real_ioctl(int fd, int request, void* arg); - -int __wrap_ioctl(int fd, int request, ...) { - va_list args; - va_start(args, request); - void* arg = va_arg(args, void*); - va_end(args); +namespace { - struct winsize windowSize {}; - if (tryAppWindowSize(fd, static_cast(request), arg, &windowSize)) { - *static_cast(arg) = windowSize; - return 0; - } - return __real_ioctl(fd, request, arg); -} +// Upper bound on wake latency while waiting on more than one fd: app streams can only be awaited one at a time. +constexpr TickType_t POLL_INTERVAL_TICKS = pdMS_TO_TICKS(10); -char* __real_getcwd(char* buf, size_t size); -int __real_chdir(const char* path); +} // namespace -char* __wrap_getcwd(char* buf, size_t size) { - char* result; - int err; - if (tryAppGetCwd(buf, size, &result, &err)) { - if (result == nullptr) { - errno = err; - } - return result; +// Non-app fds in @a fds are checked with a zero timeout real poll() on every iteration. +bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realPoll, int* outResult) { + if (fds == nullptr) { + return false; } - return __real_getcwd(buf, size); -} - -int __wrap_chdir(const char* path) { - int result; - int err; - if (tryAppChdir(path, &result, &err)) { - if (result != 0) { - errno = err; + int firstAppIndex = -1; + nfds_t activeCount = 0; + for (nfds_t i = 0; i < nfds; i++) { + if (fds[i].fd < 0) { + continue; + } + activeCount++; + if (firstAppIndex < 0 && isAppFd(fds[i].fd)) { + firstAppIndex = static_cast(i); } - return result; } - return __real_chdir(path); -} - -} - -#else - -extern "C" int __real_ioctl(int fd, unsigned long request, void* arg); - -extern "C" { - -ssize_t __wrap_read(int fd, void* buffer, size_t size) { - return app_io_read(fd, buffer, size); -} - -ssize_t __wrap_write(int fd, const void* buffer, size_t size) { - return app_io_write(fd, buffer, size); -} - -int __wrap_close(int fd) { - return app_io_close(fd); -} - -int __wrap_ioctl(int fd, unsigned long request, ...) { - va_list args; - va_start(args, request); - void* arg = va_arg(args, void*); - va_end(args); - - struct winsize windowSize {}; - if (tryAppWindowSize(fd, request, arg, &windowSize)) { - *static_cast(arg) = windowSize; - return 0; + if (firstAppIndex < 0) { + return false; } - return __real_ioctl(fd, request, arg); -} - -} - -// dlsym(RTLD_NEXT, ...) avoids recursing into our own override below. -#include -#include - -extern "C" char* __real_getcwd(char* buf, size_t size); -extern "C" int __real_chdir(const char* path); -extern "C" { - -char* __wrap_getcwd(char* buf, size_t size) { - char* result; - int err; - if (tryAppGetCwd(buf, size, &result, &err)) { - if (result == nullptr) { - errno = err; + const TickType_t start = get_ticks(); + const TickType_t timeoutTicks = (timeout < 0) ? portMAX_DELAY : pdMS_TO_TICKS(timeout); + while (true) { + int ready = 0; + for (nfds_t i = 0; i < nfds; i++) { + fds[i].revents = 0; + if (fds[i].fd < 0) { + continue; + } + uint32_t bits; + if (app_io_poll(fds[i].fd, &bits) == ERROR_NONE) { + if ((fds[i].events & POLLIN) && (bits & APP_FILE_READABLE)) { + fds[i].revents |= POLLIN; + } + if ((fds[i].events & POLLOUT) && (bits & APP_FILE_WRITABLE)) { + fds[i].revents |= POLLOUT; + } + } else { + realPoll(&fds[i], 1, 0); + } + if (fds[i].revents != 0) { + ready++; + } } - return result; - } - return __real_getcwd(buf, size); -} - -int __wrap_chdir(const char* path) { - int result; - int err; - if (tryAppChdir(path, &result, &err)) { - if (result != 0) { - errno = err; + if (ready > 0) { + *outResult = ready; + return true; } - return result; - } - return __real_chdir(path); -} - -} - -extern "C" { - -ssize_t __real_read(int fd, void* buffer, size_t size) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "read")); - return real(fd, buffer, size); -} - -ssize_t __real_write(int fd, const void* buffer, size_t size) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "write")); - return real(fd, buffer, size); -} - -int __real_close(int fd) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "close")); - return real(fd); -} - -int __real_ioctl(int fd, unsigned long request, void* arg) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "ioctl")); - return real(fd, request, arg); -} - -char* __real_getcwd(char* buf, size_t size) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "getcwd")); - return real(buf, size); -} - -int __real_chdir(const char* path) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "chdir")); - return real(path); -} - -} - -#ifdef __APPLE__ - -// isn't a public SDK header, so reimplemented locally. -#define TT_DYLD_INTERPOSE(replacement, replacee) \ - __attribute__((used)) static struct { const void* replacement; const void* replacee; } \ - tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \ - (const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \ - }; - -TT_DYLD_INTERPOSE(__wrap_read, read) -TT_DYLD_INTERPOSE(__wrap_write, write) -TT_DYLD_INTERPOSE(__wrap_ioctl, ioctl) -TT_DYLD_INTERPOSE(__wrap_close, close) -TT_DYLD_INTERPOSE(__wrap_getcwd, getcwd) -TT_DYLD_INTERPOSE(__wrap_chdir, chdir) - -#else - -extern "C" { - -ssize_t read(int fd, void* buffer, size_t size) { - return __wrap_read(fd, buffer, size); -} - -ssize_t write(int fd, const void* buffer, size_t size) { - return __wrap_write(fd, buffer, size); -} - -int close(int fd) { - return __wrap_close(fd); -} - -int ioctl(int fd, unsigned long request, ...) { - va_list args; - va_start(args, request); - void* arg = va_arg(args, void*); - va_end(args); - return __wrap_ioctl(fd, request, arg); -} - -char* getcwd(char* buf, size_t size) { - return __wrap_getcwd(buf, size); -} - -int chdir(const char* path) { - return __wrap_chdir(path); -} - -} - -#endif // __APPLE__ - -#endif // ESP_PLATFORM - -// region glibc stdio wraps -// -// libc's printf/fprintf/etc call an internal, non-exported write() alias that the read/write/close -// wraps above can't reach, so these redirect calls to printf/fprintf/etc directly. POSIX-only: -// newlib's stdio already goes through the wrappable syscall stubs. -// -// Only the process' original stdin/stdout/stderr are routed to the app's fds; every other stream -// (real file I/O, including fwrite() to a file) goes straight to libc. fread() is not wrapped. -// putc/getc are macros, not real calls, so wrapping those symbols wouldn't reliably intercept them. - -#if !defined(ESP_PLATFORM) - -#include -#include -#include -#include -#include - -extern "C" { -int __real_vfprintf(FILE* stream, const char* format, va_list args); -int __real_fputs(const char* s, FILE* stream); -int __real_fputc(int c, FILE* stream); -size_t __real_fwrite(const void* data, size_t size, size_t count, FILE* stream); -int __real_fgetc(FILE* stream); -char* __real_fgets(char* buffer, int size, FILE* stream); -} - -#include - -extern "C" { - -int __real_vfprintf(FILE* stream, const char* format, va_list args) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "vfprintf")); - return real(stream, format, args); -} - -int __real_fputs(const char* s, FILE* stream) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fputs")); - return real(s, stream); -} - -int __real_fputc(int c, FILE* stream) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fputc")); - return real(c, stream); -} - -size_t __real_fwrite(const void* data, size_t size, size_t count, FILE* stream) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fwrite")); - return real(data, size, count, stream); -} - -int __real_fgetc(FILE* stream) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fgetc")); - return real(stream); -} - -char* __real_fgets(char* buffer, int size, FILE* stream) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fgets")); - return real(buffer, size, stream); -} - -} - -namespace { -/** @return the number of bytes written, less than `size` if the fd stopped accepting data */ -size_t writeAllTo(int fd, const void* data, size_t size) { - const auto* bytes = static_cast(data); - size_t remaining = size; - while (remaining > 0) { - ssize_t written = app_io_write(fd, bytes, remaining); - if (written <= 0) { - break; + TickType_t remaining = (timeout < 0) ? portMAX_DELAY : get_timeout_remaining_ticks(timeoutTicks, start); + if (remaining == 0) { + *outResult = 0; + return true; } - bytes += written; - remaining -= static_cast(written); - } - return size - remaining; -} - -int formatTo(int fd, const char* format, va_list args) { - char stackBuffer[256]; - va_list argsForStack; - va_copy(argsForStack, args); - int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack); - va_end(argsForStack); - if (needed < 0) { - return needed; - } - if (static_cast(needed) < sizeof(stackBuffer)) { - writeAllTo(fd, stackBuffer, static_cast(needed)); - return needed; - } - auto heapBuffer = std::make_unique(static_cast(needed) + 1); - va_list argsForHeap; - va_copy(argsForHeap, args); - vsnprintf(heapBuffer.get(), static_cast(needed) + 1, format, argsForHeap); - va_end(argsForHeap); - writeAllTo(fd, heapBuffer.get(), static_cast(needed)); - return needed; -} - -int readOneFromStdin(char& out) { - return static_cast(app_io_read(STDIN_FILENO, &out, 1)); -} - -// The process' own streams, captured before anything can reassign stdin/stdout/stderr. A caller -// that points stdout at a file (e.g. the shell's redirection) must get real file I/O, so only these -// original streams are routed to the app's fds. -FILE* const originalStdin = stdin; -FILE* const originalStdout = stdout; -FILE* const originalStderr = stderr; - -int targetFdOf(FILE* stream) { - if (stream == originalStdout) return STDOUT_FILENO; - if (stream == originalStderr) return STDERR_FILENO; - return -1; -} - -} // namespace - -extern "C" { - -int __wrap_vfprintf(FILE* stream, const char* format, va_list args); -int __wrap_fputc(int c, FILE* stream); - -int __wrap_vprintf(const char* format, va_list args) { - return __wrap_vfprintf(stdout, format, args); -} - -int __wrap_printf(const char* format, ...) { - va_list args; - va_start(args, format); - int result = __wrap_vfprintf(stdout, format, args); - va_end(args); - return result; -} - -int __wrap_vfprintf(FILE* stream, const char* format, va_list args) { - int fd = targetFdOf(stream); - if (fd >= 0) { - return formatTo(fd, format, args); - } - return __real_vfprintf(stream, format, args); -} - -int __wrap_fprintf(FILE* stream, const char* format, ...) { - va_list args; - va_start(args, format); - int fd = targetFdOf(stream); - int result = (fd >= 0) ? formatTo(fd, format, args) : __real_vfprintf(stream, format, args); - va_end(args); - return result; -} - -int __wrap_puts(const char* s) { - if (targetFdOf(stdout) < 0) { - return __real_fputs(s, stdout) < 0 ? EOF : __real_fputc('\n', stdout); - } - writeAllTo(STDOUT_FILENO, s, strlen(s)); - writeAllTo(STDOUT_FILENO, "\n", 1); - return 0; -} - -int __wrap_fputs(const char* s, FILE* stream) { - int fd = targetFdOf(stream); - if (fd >= 0) { - writeAllTo(fd, s, strlen(s)); - return 0; - } - return __real_fputs(s, stream); -} - -int __wrap_putchar(int c) { - return __wrap_fputc(c, stdout); -} - -int __wrap_fputc(int c, FILE* stream) { - int fd = targetFdOf(stream); - if (fd >= 0) { - auto ch = static_cast(c); - writeAllTo(fd, &ch, 1); - return c; - } - return __real_fputc(c, stream); -} - -size_t __wrap_fwrite(const void* data, size_t size, size_t count, FILE* stream) { - int fd = targetFdOf(stream); - if (fd < 0) { - return __real_fwrite(data, size, count, stream); - } - if (size == 0 || count == 0) { - return 0; - } - return writeAllTo(fd, data, size * count) / size; -} - -int __wrap_fgetc(FILE* stream) { - if (stream != originalStdin) { - return __real_fgetc(stream); - } - char c; - return readOneFromStdin(c) == 1 ? static_cast(c) : EOF; -} - -int __wrap_getchar() { - return __wrap_fgetc(stdin); -} - -char* __wrap_fgets(char* buffer, int size, FILE* stream) { - if (stream != originalStdin) { - return __real_fgets(buffer, size, stream); - } - if (size <= 0) { - return nullptr; - } - int i = 0; - for (; i < size - 1; ++i) { - char c; - if (readOneFromStdin(c) != 1) { - break; + if (activeCount > 1 && remaining > POLL_INTERVAL_TICKS) { + remaining = POLL_INTERVAL_TICKS; } - buffer[i] = c; - if (c == '\n') { - ++i; - break; + const struct pollfd& first = fds[firstAppIndex]; + if (first.events & POLLIN) { + app_io_await(first.fd, APP_FILE_WAIT_READABLE, remaining); + } else if (first.events & POLLOUT) { + app_io_await(first.fd, APP_FILE_WAIT_WRITABLE, remaining); + } else { + delay_ticks(remaining < POLL_INTERVAL_TICKS ? remaining : POLL_INTERVAL_TICKS); } } - if (i == 0) { - return nullptr; - } - buffer[i] = '\0'; - return buffer; -} - -} - -#ifdef __APPLE__ -TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf) -TT_DYLD_INTERPOSE(__wrap_printf, printf) -TT_DYLD_INTERPOSE(__wrap_vfprintf, vfprintf) -TT_DYLD_INTERPOSE(__wrap_fprintf, fprintf) -TT_DYLD_INTERPOSE(__wrap_puts, puts) -TT_DYLD_INTERPOSE(__wrap_fputs, fputs) -TT_DYLD_INTERPOSE(__wrap_putchar, putchar) -TT_DYLD_INTERPOSE(__wrap_fputc, fputc) -TT_DYLD_INTERPOSE(__wrap_fwrite, fwrite) -TT_DYLD_INTERPOSE(__wrap_getchar, getchar) -TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc) -TT_DYLD_INTERPOSE(__wrap_fgets, fgets) -#else - -extern "C" { - -int vprintf(const char* format, va_list args) { - return __wrap_vprintf(format, args); } -int printf(const char* format, ...) { - va_list args; - va_start(args, format); - int result = __wrap_vprintf(format, args); - va_end(args); - return result; -} - -int vfprintf(FILE* stream, const char* format, va_list args) { - return __wrap_vfprintf(stream, format, args); -} - -int fprintf(FILE* stream, const char* format, ...) { - va_list args; - va_start(args, format); - int result = __wrap_vfprintf(stream, format, args); - va_end(args); - return result; -} - -int puts(const char* s) { - return __wrap_puts(s); -} - -int fputs(const char* s, FILE* stream) { - return __wrap_fputs(s, stream); -} - -int putchar(int c) { - return __wrap_putchar(c); -} - -int fputc(int c, FILE* stream) { - return __wrap_fputc(c, stream); -} - -size_t fwrite(const void* data, size_t size, size_t count, FILE* stream) { - return __wrap_fwrite(data, size, count, stream); -} - -int getchar() { - return __wrap_getchar(); -} - -int fgetc(FILE* stream) { - return __wrap_fgetc(stream); -} - -char* fgets(char* buffer, int size, FILE* stream) { - return __wrap_fgets(buffer, size, stream); -} - -} - -#endif // __APPLE__ - -#endif // !ESP_PLATFORM - -// endregion diff --git a/Modules/app-module/source/stdio_wrap_apple.cpp b/Modules/app-module/source/stdio_wrap_apple.cpp new file mode 100644 index 000000000..9a09361bb --- /dev/null +++ b/Modules/app-module/source/stdio_wrap_apple.cpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#ifdef __APPLE__ + +#include + +// isn't a public SDK header, so reimplemented locally. +#define TT_DYLD_INTERPOSE(replacement, replacee) \ + __attribute__((used)) static struct { const void* replacement; const void* replacee; } \ + tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \ + (const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \ + }; + +TT_DYLD_INTERPOSE(__wrap_read, read) +TT_DYLD_INTERPOSE(__wrap_write, write) +TT_DYLD_INTERPOSE(__wrap_ioctl, ioctl) +TT_DYLD_INTERPOSE(__wrap_close, close) +TT_DYLD_INTERPOSE(__wrap_getcwd, getcwd) +TT_DYLD_INTERPOSE(__wrap_chdir, chdir) +TT_DYLD_INTERPOSE(__wrap_fstat, fstat) +TT_DYLD_INTERPOSE(__wrap_poll, poll) +TT_DYLD_INTERPOSE(__wrap_tcgetattr, tcgetattr) +TT_DYLD_INTERPOSE(__wrap_tcsetattr, tcsetattr) + +TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf) +TT_DYLD_INTERPOSE(__wrap_printf, printf) +TT_DYLD_INTERPOSE(__wrap_vfprintf, vfprintf) +TT_DYLD_INTERPOSE(__wrap_fprintf, fprintf) +TT_DYLD_INTERPOSE(__wrap_puts, puts) +TT_DYLD_INTERPOSE(__wrap_fputs, fputs) +TT_DYLD_INTERPOSE(__wrap_putchar, putchar) +TT_DYLD_INTERPOSE(__wrap_fputc, fputc) +TT_DYLD_INTERPOSE(__wrap_fwrite, fwrite) +TT_DYLD_INTERPOSE(__wrap_getchar, getchar) +TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc) +TT_DYLD_INTERPOSE(__wrap_fgets, fgets) + +#endif // __APPLE__ diff --git a/Modules/app-module/source/stdio_wrap_elf.cpp b/Modules/app-module/source/stdio_wrap_elf.cpp new file mode 100644 index 000000000..449ce53df --- /dev/null +++ b/Modules/app-module/source/stdio_wrap_elf.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +#if !defined(ESP_PLATFORM) && !defined(__APPLE__) + +// Plain strong definitions: ELF gives the main executable's symbols priority process-wide, +// including for a dlopen()ed app's own calls. +#include + +extern "C" { + +ssize_t read(int fd, void* buffer, size_t size) { + return __wrap_read(fd, buffer, size); +} + +ssize_t write(int fd, const void* buffer, size_t size) { + return __wrap_write(fd, buffer, size); +} + +int close(int fd) { + return __wrap_close(fd); +} + +int ioctl(int fd, unsigned long request, ...) { + va_list args; + va_start(args, request); + void* arg = va_arg(args, void*); + va_end(args); + return __wrap_ioctl(fd, request, arg); +} + +char* getcwd(char* buf, size_t size) { + return __wrap_getcwd(buf, size); +} + +int chdir(const char* path) { + return __wrap_chdir(path); +} + +int fstat(int fd, struct stat* st) { + return __wrap_fstat(fd, st); +} + +int poll(struct pollfd* fds, nfds_t nfds, int timeout) { + return __wrap_poll(fds, nfds, timeout); +} + +int tcgetattr(int fd, struct termios* p) { + return __wrap_tcgetattr(fd, p); +} + +int tcsetattr(int fd, int optional_actions, const struct termios* p) { + return __wrap_tcsetattr(fd, optional_actions, p); +} + +int vprintf(const char* format, va_list args) { + return __wrap_vprintf(format, args); +} + +int printf(const char* format, ...) { + va_list args; + va_start(args, format); + int result = __wrap_vprintf(format, args); + va_end(args); + return result; +} + +int vfprintf(FILE* stream, const char* format, va_list args) { + return __wrap_vfprintf(stream, format, args); +} + +int fprintf(FILE* stream, const char* format, ...) { + va_list args; + va_start(args, format); + int result = __wrap_vfprintf(stream, format, args); + va_end(args); + return result; +} + +int puts(const char* s) { + return __wrap_puts(s); +} + +int fputs(const char* s, FILE* stream) { + return __wrap_fputs(s, stream); +} + +int putchar(int c) { + return __wrap_putchar(c); +} + +int fputc(int c, FILE* stream) { + return __wrap_fputc(c, stream); +} + +size_t fwrite(const void* data, size_t size, size_t count, FILE* stream) { + return __wrap_fwrite(data, size, count, stream); +} + +int getchar() { + return __wrap_getchar(); +} + +int fgetc(FILE* stream) { + return __wrap_fgetc(stream); +} + +char* fgets(char* buffer, int size, FILE* stream) { + return __wrap_fgets(buffer, size, stream); +} + +} + +#endif diff --git a/Modules/app-module/source/stdio_wrap_esp32.cpp b/Modules/app-module/source/stdio_wrap_esp32.cpp new file mode 100644 index 000000000..5a7a4f717 --- /dev/null +++ b/Modules/app-module/source/stdio_wrap_esp32.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +#ifdef ESP_PLATFORM + +#include + +#include + +#include +#include + +// Newlib's own stdio calls the reentrant _read_r/_write_r/_close_r stubs directly, not the plain +// read/write/close wrappers, so those stubs are wrapped instead of the plain names. ioctl() has no +// such stub (esp_libc's vfs_calls.c defines the plain name directly), so it is wrapped as-is below. +#include + +extern "C" { + +ssize_t __wrap__read_r(struct _reent* r, int fd, void* buffer, size_t size) { + (void)r; + return app_io_read(fd, buffer, size); +} + +ssize_t __wrap__write_r(struct _reent* r, int fd, const void* buffer, size_t size) { + (void)r; + return app_io_write(fd, buffer, size); +} + +int __wrap__close_r(struct _reent* r, int fd) { + (void)r; + return app_io_close(fd); +} + +int __real_ioctl(int fd, int request, void* arg); + +int __wrap_ioctl(int fd, int request, ...) { + va_list args; + va_start(args, request); + void* arg = va_arg(args, void*); + va_end(args); + + struct winsize windowSize {}; + if (tryAppWindowSize(fd, static_cast(request), arg, &windowSize)) { + *static_cast(arg) = windowSize; + return 0; + } + return __real_ioctl(fd, request, arg); +} + +char* __real_getcwd(char* buf, size_t size); +int __real_chdir(const char* path); + +char* __wrap_getcwd(char* buf, size_t size) { + char* result; + int err; + if (tryAppGetCwd(buf, size, &result, &err)) { + if (result == nullptr) { + errno = err; + } + return result; + } + return __real_getcwd(buf, size); +} + +int __wrap_chdir(const char* path) { + int result; + int err; + if (tryAppChdir(path, &result, &err)) { + if (result != 0) { + errno = err; + } + return result; + } + return __real_chdir(path); +} + +// fstat() and isatty() both go through _fstat_r +int __real__fstat_r(struct _reent* r, int fd, struct stat* st); + +int __wrap__fstat_r(struct _reent* r, int fd, struct stat* st) { + if (tryAppFstat(fd, st)) { + return 0; + } + return __real__fstat_r(r, fd, st); +} + +int __real_poll(struct pollfd* fds, nfds_t nfds, int timeout); + +int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout) { + int result; + if (tryAppPoll(fds, nfds, timeout, __real_poll, &result)) { + return result; + } + return __real_poll(fds, nfds, timeout); +} + +int __real_tcgetattr(int fd, struct termios* p); +int __real_tcsetattr(int fd, int optional_actions, const struct termios* p); + +int __wrap_tcgetattr(int fd, struct termios* p) { + if (isAppFd(fd)) { + fillAppTermios(p); + return 0; + } + return __real_tcgetattr(fd, p); +} + +int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) { + if (isAppFd(fd)) { + return 0; + } + return __real_tcsetattr(fd, optional_actions, p); +} + +} + +#endif // ESP_PLATFORM diff --git a/Modules/app-module/source/stdio_wrap_posix.cpp b/Modules/app-module/source/stdio_wrap_posix.cpp new file mode 100644 index 000000000..2c8a6b8cd --- /dev/null +++ b/Modules/app-module/source/stdio_wrap_posix.cpp @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: Apache-2.0 +#ifndef ESP_PLATFORM + +// POSIX can't use -Wl,--wrap= like ESP32: --wrap doesn't reach a dlopen()ed app's own printf/write +// calls, so these wraps are installed under their real names instead - dyld interpose on Apple +// (stdio_wrap_apple.cpp), plain strong definitions elsewhere (stdio_wrap_elf.cpp). +#include +#include + +#include + +#include +#include + +// region syscall wraps + +// dlsym(RTLD_NEXT, ...) avoids recursing into the overrides installed under the real names. +#include +#include +#include + +extern "C" { + +ssize_t __real_read(int fd, void* buffer, size_t size) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "read")); + return real(fd, buffer, size); +} + +ssize_t __real_write(int fd, const void* buffer, size_t size) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "write")); + return real(fd, buffer, size); +} + +int __real_close(int fd) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "close")); + return real(fd); +} + +int __real_ioctl(int fd, unsigned long request, void* arg) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "ioctl")); + return real(fd, request, arg); +} + +char* __real_getcwd(char* buf, size_t size) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "getcwd")); + return real(buf, size); +} + +int __real_chdir(const char* path) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "chdir")); + return real(path); +} + +int __real_fstat(int fd, struct stat* st) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstat")); + return real(fd, st); +} + +int __real_poll(struct pollfd* fds, nfds_t nfds, int timeout) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "poll")); + return real(fds, nfds, timeout); +} + +int __real_tcgetattr(int fd, struct termios* p) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "tcgetattr")); + return real(fd, p); +} + +int __real_tcsetattr(int fd, int optional_actions, const struct termios* p) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "tcsetattr")); + return real(fd, optional_actions, p); +} + +ssize_t __wrap_read(int fd, void* buffer, size_t size) { + return app_io_read(fd, buffer, size); +} + +ssize_t __wrap_write(int fd, const void* buffer, size_t size) { + return app_io_write(fd, buffer, size); +} + +int __wrap_close(int fd) { + return app_io_close(fd); +} + +int __wrap_ioctl(int fd, unsigned long request, ...) { + va_list args; + va_start(args, request); + void* arg = va_arg(args, void*); + va_end(args); + + struct winsize windowSize {}; + if (tryAppWindowSize(fd, request, arg, &windowSize)) { + *static_cast(arg) = windowSize; + return 0; + } + return __real_ioctl(fd, request, arg); +} + +char* __wrap_getcwd(char* buf, size_t size) { + char* result; + int err; + if (tryAppGetCwd(buf, size, &result, &err)) { + if (result == nullptr) { + errno = err; + } + return result; + } + return __real_getcwd(buf, size); +} + +int __wrap_chdir(const char* path) { + int result; + int err; + if (tryAppChdir(path, &result, &err)) { + if (result != 0) { + errno = err; + } + return result; + } + return __real_chdir(path); +} + +int __wrap_fstat(int fd, struct stat* st) { + if (tryAppFstat(fd, st)) { + return 0; + } + return __real_fstat(fd, st); +} + +int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout) { + int result; + if (tryAppPoll(fds, nfds, timeout, __real_poll, &result)) { + return result; + } + return __real_poll(fds, nfds, timeout); +} + +int __wrap_tcgetattr(int fd, struct termios* p) { + if (isAppFd(fd)) { + fillAppTermios(p); + return 0; + } + return __real_tcgetattr(fd, p); +} + +int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) { + if (isAppFd(fd)) { + return 0; + } + return __real_tcsetattr(fd, optional_actions, p); +} + +} + +// endregion + +// region stdio wraps +// +// libc's printf/fprintf/etc call an internal, non-exported write() alias that the read/write/close +// wraps above can't reach, so these redirect calls to printf/fprintf/etc directly. POSIX-only: +// newlib's stdio already goes through the wrappable syscall stubs. +// +// Only the process' original stdin/stdout/stderr are routed to the app's fds; every other stream +// (real file I/O, including fwrite() to a file) goes straight to libc. fread() is not wrapped. +// putc/getc are macros, not real calls, so wrapping those symbols wouldn't reliably intercept them. + +extern "C" { + +int __real_vfprintf(FILE* stream, const char* format, va_list args) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "vfprintf")); + return real(stream, format, args); +} + +int __real_fputs(const char* s, FILE* stream) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fputs")); + return real(s, stream); +} + +int __real_fputc(int c, FILE* stream) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fputc")); + return real(c, stream); +} + +size_t __real_fwrite(const void* data, size_t size, size_t count, FILE* stream) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fwrite")); + return real(data, size, count, stream); +} + +int __real_fgetc(FILE* stream) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fgetc")); + return real(stream); +} + +char* __real_fgets(char* buffer, int size, FILE* stream) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fgets")); + return real(buffer, size, stream); +} + +} + +namespace { + +/** @return the number of bytes written, less than `size` if the fd stopped accepting data */ +size_t writeAllTo(int fd, const void* data, size_t size) { + const auto* bytes = static_cast(data); + size_t remaining = size; + while (remaining > 0) { + ssize_t written = app_io_write(fd, bytes, remaining); + if (written <= 0) { + break; + } + bytes += written; + remaining -= static_cast(written); + } + return size - remaining; +} + +int formatTo(int fd, const char* format, va_list args) { + char stackBuffer[256]; + va_list argsForStack; + va_copy(argsForStack, args); + int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack); + va_end(argsForStack); + if (needed < 0) { + return needed; + } + if (static_cast(needed) < sizeof(stackBuffer)) { + writeAllTo(fd, stackBuffer, static_cast(needed)); + return needed; + } + auto heapBuffer = std::make_unique(static_cast(needed) + 1); + va_list argsForHeap; + va_copy(argsForHeap, args); + vsnprintf(heapBuffer.get(), static_cast(needed) + 1, format, argsForHeap); + va_end(argsForHeap); + writeAllTo(fd, heapBuffer.get(), static_cast(needed)); + return needed; +} + +int readOneFromStdin(char& out) { + return static_cast(app_io_read(STDIN_FILENO, &out, 1)); +} + +// The process' own streams, captured before anything can reassign stdin/stdout/stderr. A caller +// that points stdout at a file (e.g. the shell's redirection) must get real file I/O, so only these +// original streams are routed to the app's fds. +FILE* const originalStdin = stdin; +FILE* const originalStdout = stdout; +FILE* const originalStderr = stderr; + +int targetFdOf(FILE* stream) { + if (stream == originalStdout) return STDOUT_FILENO; + if (stream == originalStderr) return STDERR_FILENO; + return -1; +} + +} // namespace + +extern "C" { + +int __wrap_vprintf(const char* format, va_list args) { + return __wrap_vfprintf(stdout, format, args); +} + +int __wrap_printf(const char* format, ...) { + va_list args; + va_start(args, format); + int result = __wrap_vfprintf(stdout, format, args); + va_end(args); + return result; +} + +int __wrap_vfprintf(FILE* stream, const char* format, va_list args) { + int fd = targetFdOf(stream); + if (fd >= 0) { + return formatTo(fd, format, args); + } + return __real_vfprintf(stream, format, args); +} + +int __wrap_fprintf(FILE* stream, const char* format, ...) { + va_list args; + va_start(args, format); + int fd = targetFdOf(stream); + int result = (fd >= 0) ? formatTo(fd, format, args) : __real_vfprintf(stream, format, args); + va_end(args); + return result; +} + +int __wrap_puts(const char* s) { + if (targetFdOf(stdout) < 0) { + return __real_fputs(s, stdout) < 0 ? EOF : __real_fputc('\n', stdout); + } + writeAllTo(STDOUT_FILENO, s, strlen(s)); + writeAllTo(STDOUT_FILENO, "\n", 1); + return 0; +} + +int __wrap_fputs(const char* s, FILE* stream) { + int fd = targetFdOf(stream); + if (fd >= 0) { + writeAllTo(fd, s, strlen(s)); + return 0; + } + return __real_fputs(s, stream); +} + +int __wrap_putchar(int c) { + return __wrap_fputc(c, stdout); +} + +int __wrap_fputc(int c, FILE* stream) { + int fd = targetFdOf(stream); + if (fd >= 0) { + auto ch = static_cast(c); + writeAllTo(fd, &ch, 1); + return c; + } + return __real_fputc(c, stream); +} + +size_t __wrap_fwrite(const void* data, size_t size, size_t count, FILE* stream) { + int fd = targetFdOf(stream); + if (fd < 0) { + return __real_fwrite(data, size, count, stream); + } + if (size == 0 || count == 0) { + return 0; + } + return writeAllTo(fd, data, size * count) / size; +} + +int __wrap_fgetc(FILE* stream) { + if (stream != originalStdin) { + return __real_fgetc(stream); + } + char c; + return readOneFromStdin(c) == 1 ? static_cast(c) : EOF; +} + +int __wrap_getchar() { + return __wrap_fgetc(stdin); +} + +char* __wrap_fgets(char* buffer, int size, FILE* stream) { + if (stream != originalStdin) { + return __real_fgets(buffer, size, stream); + } + if (size <= 0) { + return nullptr; + } + int i = 0; + for (; i < size - 1; ++i) { + char c; + if (readOneFromStdin(c) != 1) { + break; + } + buffer[i] = c; + if (c == '\n') { + ++i; + break; + } + } + if (i == 0) { + return nullptr; + } + buffer[i] = '\0'; + return buffer; +} + +} + +// endregion + +#endif // ESP_PLATFORM diff --git a/Modules/app-module/tests/CMakeLists.txt b/Modules/app-module/tests/CMakeLists.txt index 6b47749f2..78bc313bc 100644 --- a/Modules/app-module/tests/CMakeLists.txt +++ b/Modules/app-module/tests/CMakeLists.txt @@ -10,7 +10,7 @@ target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC} ${CMAKE_CURRENT_ add_test(NAME AppModuleTests COMMAND AppModuleTests) -# No --wrap flags or stdio_wrap.cpp source needed here: linking app-module below already brings +# No --wrap flags or stdio_wrap sources needed here: linking app-module below already brings # both along (see its own CMakeLists.txt). target_link_libraries(AppModuleTests PUBLIC diff --git a/Modules/app-module/tests/source/io_test.cpp b/Modules/app-module/tests/source/io_test.cpp index 68f7e4704..801c1c01d 100644 --- a/Modules/app-module/tests/source/io_test.cpp +++ b/Modules/app-module/tests/source/io_test.cpp @@ -13,6 +13,9 @@ #include #include +#include +#include +#include #include #include @@ -203,8 +206,73 @@ int32_t double_close_app_main(int, char*[]) { return 0; } +std::atomic g_posix_stdin_is_char_device { false }; +std::atomic g_posix_tcgetattr_result { -2 }; +std::atomic g_posix_stdin_is_raw { false }; +std::atomic g_posix_tcsetattr_result { -2 }; +std::atomic g_posix_poll_before_write { -2 }; +std::atomic g_posix_poll_after_write { -2 }; +std::atomic g_posix_poll_first_done { false }; + +int32_t posix_calls_app_main(int, char*[]) { + struct stat st {}; + g_posix_stdin_is_char_device.store(fstat(STDIN_FILENO, &st) == 0 && S_ISCHR(st.st_mode), std::memory_order_release); + + struct termios t {}; + g_posix_tcgetattr_result.store(tcgetattr(STDIN_FILENO, &t), std::memory_order_release); + g_posix_stdin_is_raw.store((t.c_lflag & (ICANON | ECHO)) == 0, std::memory_order_release); + g_posix_tcsetattr_result.store(tcsetattr(STDIN_FILENO, TCSANOW, &t), std::memory_order_release); + + struct pollfd fds { STDIN_FILENO, POLLIN, 0 }; + g_posix_poll_before_write.store(poll(&fds, 1, 50), std::memory_order_release); + g_posix_poll_first_done.store(true, std::memory_order_release); + g_posix_poll_after_write.store(poll(&fds, 1, 1000), std::memory_order_release); + return 0; +} + } // namespace +TEST_CASE("fstat, termios and poll on a bound app stdin report a raw character device that becomes readable") { + ensure_memory_loader_registered(); + g_posix_stdin_is_char_device.store(false, std::memory_order_relaxed); + g_posix_tcgetattr_result.store(-2, std::memory_order_relaxed); + g_posix_stdin_is_raw.store(false, std::memory_order_relaxed); + g_posix_tcsetattr_result.store(-2, std::memory_order_relaxed); + g_posix_poll_before_write.store(-2, std::memory_order_relaxed); + g_posix_poll_after_write.store(-2, std::memory_order_relaxed); + g_posix_poll_first_done.store(false, std::memory_order_relaxed); + + AppManifest manifest { "test.io.posix_calls", "PosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(posix_calls_app_main) } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + + uint8_t storage[16]; + AppStream child_stdin {}; + AppStreamBinding binding { STDIN_FILENO, &child_stdin, storage, sizeof(storage), &event_group, {}, -1 }; + AppInstanceId child_id = 0; + AppStartContext context; + REQUIRE_EQ(app_start_context_from_id("test.io.posix_calls", &context), ERROR_NONE); + app_start_context_set_streams(&context, &binding, 1); + REQUIRE_EQ(app_start_with_context(&context, &child_id), ERROR_NONE); + + REQUIRE(wait_for_flag(g_posix_poll_first_done, 1000)); + app_stream_write(&child_stdin, "x", 1); + + REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000)); + CHECK(g_posix_stdin_is_char_device.load(std::memory_order_acquire)); + CHECK_EQ(g_posix_tcgetattr_result.load(std::memory_order_acquire), 0); + CHECK(g_posix_stdin_is_raw.load(std::memory_order_acquire)); + CHECK_EQ(g_posix_tcsetattr_result.load(std::memory_order_acquire), 0); + CHECK_EQ(g_posix_poll_before_write.load(std::memory_order_acquire), 0); + CHECK_EQ(g_posix_poll_after_write.load(std::memory_order_acquire), 1); + + app_stream_unsubscribe(&child_stdin); + task_event_group_destruct(&event_group); + app_manager_remove("test.io.posix_calls"); +} + TEST_CASE("an app's stdio fds default to the null device: write succeeds and discards, read reports EOF") { ensure_memory_loader_registered(); g_stdio_write_result.store(-2, std::memory_order_relaxed); diff --git a/Modules/app-posix-module/tests/CMakeLists.txt b/Modules/app-posix-module/tests/CMakeLists.txt index 7d17d97e5..6de21b0e6 100644 --- a/Modules/app-posix-module/tests/CMakeLists.txt +++ b/Modules/app-posix-module/tests/CMakeLists.txt @@ -13,7 +13,7 @@ set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPEND # Fixture: calls printf() with no app-module linkage of its own, so its call must resolve against # the host process's own printf - proves a dlopen()ed app's stdio actually reaches app_io_write() -# rather than bypassing straight to libc (see stdio_wrap.cpp's own comment for why that's not a +# rather than bypassing straight to libc (see stdio_wrap_posix.cpp's own comment for why that's not a # given on POSIX). add_library(printf_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/fixtures/printf_fixture.cpp) set_target_properties(printf_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/Modules/c-symbols-module/source/module.cpp b/Modules/c-symbols-module/source/module.cpp index ffb51fb94..623e4dc59 100644 --- a/Modules/c-symbols-module/source/module.cpp +++ b/Modules/c-symbols-module/source/module.cpp @@ -9,6 +9,7 @@ #include #include #include +#include extern "C" { @@ -155,6 +156,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(strncat), DEFINE_MODULE_SYMBOL(strspn), DEFINE_MODULE_SYMBOL(strcoll), + DEFINE_MODULE_SYMBOL(strtok), DEFINE_MODULE_SYMBOL(memset), DEFINE_MODULE_SYMBOL(memcpy), DEFINE_MODULE_SYMBOL(memcmp), @@ -180,6 +182,10 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(toupper), // locale.h DEFINE_MODULE_SYMBOL(localeconv), + // regex.h + DEFINE_MODULE_SYMBOL(regcomp), + DEFINE_MODULE_SYMBOL(regexec), + DEFINE_MODULE_SYMBOL(regfree), MODULE_SYMBOL_TERMINATOR }; diff --git a/Modules/posix-symbols-module/source/module.cpp b/Modules/posix-symbols-module/source/module.cpp index 3ac1ea6a7..cf853bc95 100644 --- a/Modules/posix-symbols-module/source/module.cpp +++ b/Modules/posix-symbols-module/source/module.cpp @@ -9,7 +9,10 @@ #include #include #include +#include +#include #include +#include #include #if __has_include() @@ -33,6 +36,17 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(read), DEFINE_MODULE_SYMBOL(write), DEFINE_MODULE_SYMBOL(lseek), + DEFINE_MODULE_SYMBOL(getcwd), + DEFINE_MODULE_SYMBOL(chdir), + DEFINE_MODULE_SYMBOL(ftruncate), + DEFINE_MODULE_SYMBOL(fsync), + // sys/ioctl.h + DEFINE_MODULE_SYMBOL(ioctl), + // sys/poll.h + DEFINE_MODULE_SYMBOL(poll), + // termios.h + DEFINE_MODULE_SYMBOL(tcgetattr), + DEFINE_MODULE_SYMBOL(tcsetattr), // strings.h #if defined(__BSD_VISIBLE) && __BSD_VISIBLE DEFINE_MODULE_SYMBOL(explicit_bzero), @@ -56,6 +70,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(fcntl), // sys/stat.h DEFINE_MODULE_SYMBOL(stat), + DEFINE_MODULE_SYMBOL(fstat), DEFINE_MODULE_SYMBOL(mkdir), // stdlib.h DEFINE_MODULE_SYMBOL(rand_r), diff --git a/Tactility/Source/app/shell/LineEditor.cpp b/Tactility/Source/app/shell/LineEditor.cpp index f40705aa6..9ad45420d 100644 --- a/Tactility/Source/app/shell/LineEditor.cpp +++ b/Tactility/Source/app/shell/LineEditor.cpp @@ -13,7 +13,7 @@ namespace { // and cursor movement belong to the terminal itself and must never end up in a command's redirect // target. sh_redir.c only ever swaps the stdout/stderr/stdin FILE*, never fd 1 itself, which is // always piped to the terminal app running it. fwrite() would also miss this app's own stdio -// wrapping entirely (see stdio_wrap.cpp), leaving nothing for that pipe to carry. +// wrapping entirely (see stdio_wrap_posix.cpp), leaving nothing for that pipe to carry. void write(const char* text, size_t length) { const char* bytes = text; size_t remaining = length; diff --git a/Tactility/Source/app/terminal/vterm/vterm.c b/Tactility/Source/app/terminal/vterm/vterm.c index 89d204527..ebdba1490 100644 --- a/Tactility/Source/app/terminal/vterm/vterm.c +++ b/Tactility/Source/app/terminal/vterm/vterm.c @@ -434,11 +434,24 @@ static int vterm_handle_escape(vterm_t *vt, char c) switch (c) { case 'm': vterm_apply_sgr(vt, vt->escape_buf); break; - case 'J': - if (strcmp(vt->escape_buf, "2") == 0 || strcmp(vt->escape_buf, "") == 0) { + case 'J': { // Erase in Display + int mode = 0; + if (vt->escape_buf[0]) mode = atoi(vt->escape_buf); + if (mode == 2) { vterm_clear_internal(vt); + break; + } + int cursor = vt->cursor_y * VTERM_COLS + vt->cursor_x; + int start = 0, end = VTERM_ROWS * VTERM_COLS; + if (mode == 0) start = cursor; // Cursor to end of screen + else if (mode == 1) end = cursor + 1; // Start of screen to cursor + else break; + for (int i = start; i < end; i++) { + vt->cells[i].ch = ' '; + vt->cells[i].attr = vt->current_attr; } break; + } case 'H': case 'f': if (vt->escape_buf[0] == '\0' || strcmp(vt->escape_buf, "1;1") == 0) { diff --git a/device.py b/device.py index 0a8ea9086..1dc1fdac9 100755 --- a/device.py +++ b/device.py @@ -165,6 +165,8 @@ def write_core_variables(output_file, device_properties: dict): # newlib-internal symbols (_ctype_, __getreent) this repo's ELF-loader ABI exports. # Stay on Newlib rather than re-auditing every per-task stdio assumption. output_file.write("CONFIG_LIBC_NEWLIB=y\n") + # Exposes termios.h and tcgetattr()/tcsetattr() to firmware and side-loaded apps. + output_file.write("CONFIG_VFS_SUPPORT_TERMIOS=y\n") output_file.write("# CPU\n") output_file.write("CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y\n") output_file.write("CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240\n") diff --git a/Tests/SdkIntegration/tactility.py b/tactility.py similarity index 93% rename from Tests/SdkIntegration/tactility.py rename to tactility.py index 51fdb6271..c7db51b8f 100755 --- a/Tests/SdkIntegration/tactility.py +++ b/tactility.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# License: Apache License v2 import json import os @@ -14,7 +15,7 @@ from urllib.parse import urlparse ttbuild_path = ".tactility" -ttbuild_version = "6.1.0" +ttbuild_version = "7.0.0" ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_sdk_json_validity = 3600 # seconds ttport = 6666 @@ -272,8 +273,11 @@ def parse_command_line(argv): #region SDK helpers -def read_sdk_json(): - json_file_path = os.path.join(ttbuild_path, "tool.json") +def get_tool_json_path(version): + return os.path.join(ttbuild_path, version, "tactility.py.json") + +def read_sdk_json(version): + json_file_path = get_tool_json_path(version) with open(json_file_path) as json_file: return json.load(json_file) @@ -318,9 +322,8 @@ def sdk_exists(version, platform): sdk_dir = get_sdk_dir(version, platform) return os.path.isdir(sdk_dir) -def should_update_tool_json(): - global ttbuild_cdn - json_filepath = os.path.join(ttbuild_path, "tool.json") +def should_update_tool_json(version): + json_filepath = get_tool_json_path(version) if os.path.exists(json_filepath): json_modification_time = os.path.getmtime(json_filepath) now = time.time() @@ -330,27 +333,16 @@ def should_update_tool_json(): else: return True -def update_tool_json(): - global ttbuild_cdn, ttbuild_path - json_url = f"{ttbuild_cdn}/sdk/tool.json" - json_filepath = os.path.join(ttbuild_path, "tool.json") - return download_file(json_url, json_filepath) - -def should_fetch_sdkconfig_files(platform_targets): - for platform in platform_targets: - if not platform.startswith("posix"): - sdkconfig_filename = f"sdkconfig.app.{platform}" - if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)): - return True - return False +def update_tool_json(version): + json_filepath = get_tool_json_path(version) + os.makedirs(os.path.dirname(json_filepath), exist_ok=True) + return download_file(get_sdk_url(version, "tactility.py.json"), json_filepath) -def fetch_sdkconfig_files(platform_targets): - for platform in platform_targets: - if not platform.startswith("posix"): - sdkconfig_filename = f"sdkconfig.app.{platform}" - target_path = os.path.join(ttbuild_path, sdkconfig_filename) - if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path): - exit_with_error(f"Failed to download sdkconfig file for {platform}") +def copy_sdk_sdkconfig(version, platform): + sdkconfig_path = os.path.join(get_sdk_dir(version, platform), f"sdkconfig.app.{platform}") + if not os.path.isfile(sdkconfig_path): + exit_with_error(f"SDK does not contain {sdkconfig_path}") + shutil.copy(sdkconfig_path, "sdkconfig") #endregion SDK helpers @@ -371,21 +363,12 @@ def validate_environment(platforms): exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") def validate_self(sdk_json): - if not "toolVersion" in sdk_json: - exit_with_error("Server returned invalid SDK data format (toolVersion not found)") - if not "toolCompatibility" in sdk_json: - exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)") - if not "toolDownloadUrl" in sdk_json: - exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)") - tool_version = sdk_json["toolVersion"] - tool_compatibility = sdk_json["toolCompatibility"] + if not "version" in sdk_json: + exit_with_error("Server returned invalid SDK data format (version not found)") + tool_version = sdk_json["version"] if SemanticVersion.parse(ttbuild_version) < SemanticVersion.parse(tool_version): print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") print_warning(f"Run 'tactility.py updateself' to update.") - if re.search(tool_compatibility, ttbuild_version) is None: - print_error("The tool is not compatible anymore.") - print_error("Run 'tactility.py updateself' to update.") - sys.exit(1) #endregion Validation @@ -669,9 +652,7 @@ def build_first(version, platform, skip_build): if verbose: print(f"Using SDK at {sdk_dir}") os.environ["TACTILITY_SDK_PATH"] = sdk_dir - sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") - if not platform.startswith("posix"): - shutil.copy(sdkconfig_path, "sdkconfig") + copy_sdk_sdkconfig(version, platform) elf_path = find_elf_file(platform) # Remove previous elf file: re-creation of the file is used to measure if the build succeeded, # as the actual build job will always fail due to technical issues with the elf cmake script @@ -707,8 +688,7 @@ def build_consecutively(version, platform, skip_build): if verbose: print(f"Using SDK at {sdk_dir}") os.environ["TACTILITY_SDK_PATH"] = sdk_dir - sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") - shutil.copy(sdkconfig_path, "sdkconfig") + copy_sdk_sdkconfig(version, platform) if skip_build: return True cmake_path = get_cmake_path(platform) @@ -822,11 +802,8 @@ def build_action(manifest, arguments): local_base_path = os.environ.get("TACTILITY_SDK_PATH") validate_local_sdks(platforms_to_build, manifest["target.sdk"]) - if should_fetch_sdkconfig_files(platforms_to_build): - fetch_sdkconfig_files(platforms_to_build) - if not use_local_sdk: - sdk_json = read_sdk_json() + sdk_json = read_sdk_json(manifest["target.sdk"]) validate_self(sdk_json) # Build sdk_version = manifest["target.sdk"] @@ -886,10 +863,8 @@ def clear_cache_action(): else: print("Nothing to clear") -def update_self_action(): - sdk_json = read_sdk_json() - tool_download_url = sdk_json["toolDownloadUrl"] - if download_file(tool_download_url, "tactility.py"): +def update_self_action(manifest): + if download_file(get_sdk_url(manifest["target.sdk"], "tactility.py"), "tactility.py"): print("Updated") else: exit_with_error("Update failed") @@ -1010,8 +985,9 @@ def uninstall_action(manifest, arguments): exit_with_error("manifest.properties not found") manifest = read_manifest() validate_manifest(manifest) - # Update SDK cache (tool.json) - if not use_local_sdk and should_update_tool_json() and not update_tool_json(): + # Update SDK cache (tactility.py.json) + sdk_version = manifest["target.sdk"] + if not use_local_sdk and should_update_tool_json(sdk_version) and not update_tool_json(sdk_version): exit_with_error("Failed to retrieve SDK info") # Actions action_arg = parsed_command.action @@ -1023,7 +999,7 @@ def uninstall_action(manifest, arguments): elif action_arg == "clearcache": clear_cache_action() elif action_arg == "updateself": - update_self_action() + update_self_action(manifest) elif action_arg == "run": run_action(manifest, parsed_command.arguments) elif action_arg == "install": diff --git a/tactility.py.json b/tactility.py.json new file mode 100644 index 000000000..d56c898b1 --- /dev/null +++ b/tactility.py.json @@ -0,0 +1,3 @@ +{ + "version": "7.0.0" +} From 4e969073d1f850a647962464d15f615f52ba9f44 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 13:42:41 +0200 Subject: [PATCH 3/9] Fix for mac builds --- Modules/app-module/source/stdio_wrap_apple.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/app-module/source/stdio_wrap_apple.cpp b/Modules/app-module/source/stdio_wrap_apple.cpp index 9a09361bb..427546e45 100644 --- a/Modules/app-module/source/stdio_wrap_apple.cpp +++ b/Modules/app-module/source/stdio_wrap_apple.cpp @@ -3,6 +3,9 @@ #include +#include +#include + // isn't a public SDK header, so reimplemented locally. #define TT_DYLD_INTERPOSE(replacement, replacee) \ __attribute__((used)) static struct { const void* replacement; const void* replacee; } \ From 2ffe0725364a3f3e392dc4afde61575158a0539a Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 13:42:49 +0200 Subject: [PATCH 4/9] Fix for partition size --- partitions-32mb-no-sd.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/partitions-32mb-no-sd.csv b/partitions-32mb-no-sd.csv index 70a6feb2a..a4e7e15f8 100644 --- a/partitions-32mb-no-sd.csv +++ b/partitions-32mb-no-sd.csv @@ -4,4 +4,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 6M, system, data, fat, , 128k, -data, data, fat, , 26480k, +data, data, fat, , 25M, From e10566f5e3ca56a0978f657ebdfe4b0f5e06d0db Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 15:06:40 +0200 Subject: [PATCH 5/9] Fixes and improvements --- Buildscripts/CDN/upload-sdk-files.py | 2 +- CMakeLists.txt | 16 ++++ Modules/c-symbols-module/source/module.cpp | 1 + .../posix-symbols-module/source/module.cpp | 2 + Platforms/platform-esp32/source/mkdir.cpp | 26 +++++ Platforms/platform-esp32/source/root_dir.cpp | 8 +- Platforms/platform-esp32/source/unistd.cpp | 8 ++ .../platform-esp32/source/vfs_null_path.cpp | 96 +++++++++++++++++++ Tactility/Private/Tactility/app/shell/Shell.h | 2 + Tactility/Source/app/shell/Shell.cpp | 62 +++++++++--- .../Tests/Source/ShellCompletionTest.cpp | 23 +++++ tactility.py | 3 + 12 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 Platforms/platform-esp32/source/mkdir.cpp create mode 100644 Platforms/platform-esp32/source/unistd.cpp create mode 100644 Platforms/platform-esp32/source/vfs_null_path.cpp diff --git a/Buildscripts/CDN/upload-sdk-files.py b/Buildscripts/CDN/upload-sdk-files.py index 72dfd96a6..a6942844e 100644 --- a/Buildscripts/CDN/upload-sdk-files.py +++ b/Buildscripts/CDN/upload-sdk-files.py @@ -32,7 +32,7 @@ def create_tool_json(): match = re.search(r'^ttbuild_version = "([^"]+)"', file.read(), re.MULTILINE) if match is None: exit_with_error(f"ttbuild_version not found in {TOOL_PATH}") - return json.dumps({"toolVersion": match.group(1)}, indent=2).encode("utf-8") + return json.dumps({"version": match.group(1)}, indent=2).encode("utf-8") def main(path: str, version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool): if not os.path.exists(path): diff --git a/CMakeLists.txt b/CMakeLists.txt index c79216f64..307663efa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,22 @@ if (DEFINED ENV{ESP_IDF_VERSION}) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=readdir" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=closedir" APPEND) + # mkdir() is an alias in vfs_calls.c. Wrapped so an existing mount root reports EEXIST. + # See Platforms/platform-esp32/source/mkdir.cpp. + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=mkdir" APPEND) + + # Path-based VFS calls are aliases in vfs_calls.c. Wrapped so a NULL path fails with EFAULT instead of crashing. + # See Platforms/platform-esp32/source/vfs_null_path.cpp. + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_open_r" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_stat_r" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_link_r" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_unlink_r" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_rename_r" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=truncate" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=access" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=utime" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=rmdir" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_button_create" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_dropdown_create" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_list_create" APPEND) diff --git a/Modules/c-symbols-module/source/module.cpp b/Modules/c-symbols-module/source/module.cpp index 623e4dc59..a6b57459f 100644 --- a/Modules/c-symbols-module/source/module.cpp +++ b/Modules/c-symbols-module/source/module.cpp @@ -148,6 +148,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL_SIGNATURE(strrchr, const char* (*)(const char*, int)), DEFINE_MODULE_SYMBOL_SIGNATURE(strpbrk, const char* (*)(const char*, const char*)), DEFINE_MODULE_SYMBOL_SIGNATURE(memchr, const void* (*)(const void*, int, size_t)), + DEFINE_MODULE_SYMBOL_SIGNATURE(memrchr, const void* (*)(const void*, int, size_t)), DEFINE_MODULE_SYMBOL(strerror), DEFINE_MODULE_SYMBOL(strtod), DEFINE_MODULE_SYMBOL(strtol), diff --git a/Modules/posix-symbols-module/source/module.cpp b/Modules/posix-symbols-module/source/module.cpp index cf853bc95..918dd76a1 100644 --- a/Modules/posix-symbols-module/source/module.cpp +++ b/Modules/posix-symbols-module/source/module.cpp @@ -40,6 +40,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(chdir), DEFINE_MODULE_SYMBOL(ftruncate), DEFINE_MODULE_SYMBOL(fsync), + DEFINE_MODULE_SYMBOL(getuid), // sys/ioctl.h DEFINE_MODULE_SYMBOL(ioctl), // sys/poll.h @@ -85,6 +86,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(opterr), DEFINE_MODULE_SYMBOL(optarg), DEFINE_MODULE_SYMBOL(optopt), + DEFINE_MODULE_SYMBOL(getopt), #ifdef POSIX_SYMBOLS_HAS_GETOPT_LONG DEFINE_MODULE_SYMBOL(getopt_long), #endif diff --git a/Platforms/platform-esp32/source/mkdir.cpp b/Platforms/platform-esp32/source/mkdir.cpp new file mode 100644 index 000000000..0a382800e --- /dev/null +++ b/Platforms/platform-esp32/source/mkdir.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +// mkdir() on an existing FATFS mount root (e.g. "/sdcard") fails without setting EEXIST, +// which breaks the common "mkdir() then accept EEXIST" pattern. Any existing path reports EEXIST. +// A NULL path fails with EFAULT instead of crashing in the VFS (see vfs_null_path.cpp). +extern "C" { + +int __real_mkdir(const char* path, mode_t mode); + +int __wrap_mkdir(const char* path, mode_t mode) { + if (path == nullptr) { + errno = EFAULT; + return -1; + } + struct stat info; + if (stat(path, &info) == 0) { + errno = EEXIST; + return -1; + } + return __real_mkdir(path, mode); +} + +} diff --git a/Platforms/platform-esp32/source/root_dir.cpp b/Platforms/platform-esp32/source/root_dir.cpp index aa858674d..09305011a 100644 --- a/Platforms/platform-esp32/source/root_dir.cpp +++ b/Platforms/platform-esp32/source/root_dir.cpp @@ -15,6 +15,7 @@ #include +#include #include #include #include @@ -70,7 +71,12 @@ extern "C" int __real_closedir(DIR* pdir); extern "C" { DIR* __wrap_opendir(const char* name) { - if (name == nullptr || strcmp(name, "/") != 0) { + // The VFS dereferences a NULL path (see vfs_null_path.cpp) + if (name == nullptr) { + errno = EFAULT; + return nullptr; + } + if (strcmp(name, "/") != 0) { return __real_opendir(name); } diff --git a/Platforms/platform-esp32/source/unistd.cpp b/Platforms/platform-esp32/source/unistd.cpp new file mode 100644 index 000000000..fdced439b --- /dev/null +++ b/Platforms/platform-esp32/source/unistd.cpp @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +// ESP-IDF doesn't provide getuid(). Tactility has no user accounts, so everything runs as root. +extern "C" uid_t getuid() { + return 0; +} diff --git a/Platforms/platform-esp32/source/vfs_null_path.cpp b/Platforms/platform-esp32/source/vfs_null_path.cpp new file mode 100644 index 000000000..179ba388c --- /dev/null +++ b/Platforms/platform-esp32/source/vfs_null_path.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include + +#include + +// ESP-IDF's VFS dereferences a NULL path. These fail with EFAULT instead, like other POSIX systems do. +// opendir() and mkdir() get the same check in their own wraps (root_dir.cpp, mkdir.cpp). +extern "C" { + +int __real__open_r(struct _reent* r, const char* path, int flags, int mode); +int __real__stat_r(struct _reent* r, const char* path, struct stat* st); +int __real__link_r(struct _reent* r, const char* n1, const char* n2); +int __real__unlink_r(struct _reent* r, const char* path); +int __real__rename_r(struct _reent* r, const char* src, const char* dst); +int __real_truncate(const char* path, off_t length); +int __real_access(const char* path, int amode); +int __real_utime(const char* path, const struct utimbuf* times); +int __real_rmdir(const char* name); + +// open() and fopen() both go through _open_r +int __wrap__open_r(struct _reent* r, const char* path, int flags, int mode) { + if (path == nullptr) { + r->_errno = EFAULT; + return -1; + } + return __real__open_r(r, path, flags, mode); +} + +int __wrap__stat_r(struct _reent* r, const char* path, struct stat* st) { + if (path == nullptr) { + r->_errno = EFAULT; + return -1; + } + return __real__stat_r(r, path, st); +} + +int __wrap__link_r(struct _reent* r, const char* n1, const char* n2) { + if (n1 == nullptr || n2 == nullptr) { + r->_errno = EFAULT; + return -1; + } + return __real__link_r(r, n1, n2); +} + +int __wrap__unlink_r(struct _reent* r, const char* path) { + if (path == nullptr) { + r->_errno = EFAULT; + return -1; + } + return __real__unlink_r(r, path); +} + +int __wrap__rename_r(struct _reent* r, const char* src, const char* dst) { + if (src == nullptr || dst == nullptr) { + r->_errno = EFAULT; + return -1; + } + return __real__rename_r(r, src, dst); +} + +int __wrap_truncate(const char* path, off_t length) { + if (path == nullptr) { + errno = EFAULT; + return -1; + } + return __real_truncate(path, length); +} + +int __wrap_access(const char* path, int amode) { + if (path == nullptr) { + errno = EFAULT; + return -1; + } + return __real_access(path, amode); +} + +int __wrap_utime(const char* path, const struct utimbuf* times) { + if (path == nullptr) { + errno = EFAULT; + return -1; + } + return __real_utime(path, times); +} + +int __wrap_rmdir(const char* name) { + if (name == nullptr) { + errno = EFAULT; + return -1; + } + return __real_rmdir(name); +} + +} diff --git a/Tactility/Private/Tactility/app/shell/Shell.h b/Tactility/Private/Tactility/app/shell/Shell.h index 3a2015909..2b46db1ad 100644 --- a/Tactility/Private/Tactility/app/shell/Shell.h +++ b/Tactility/Private/Tactility/app/shell/Shell.h @@ -14,6 +14,8 @@ namespace Shell { struct Command { const char* name; const char* help; + /** Binary to run for an installed app, nullptr for a built-in one. */ + const char* path; }; /** Registers the builtin command set. */ diff --git a/Tactility/Source/app/shell/Shell.cpp b/Tactility/Source/app/shell/Shell.cpp index 13c8b525b..16d0ab7a8 100644 --- a/Tactility/Source/app/shell/Shell.cpp +++ b/Tactility/Source/app/shell/Shell.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -65,18 +66,49 @@ void shutdown() { void forEachCommand(void* context, void (*callback)(const Command&, void*)) { struct CommandId { AppId id; }; - std::vector ids; - // First gather all IDs, so we don't keep the ledger lock while calling callback() + struct InstalledCommand { + char name[APP_MANIFEST_BINARY_LENGTH + 1]; + char path[FILE_MAX_PATH_STRING_LENGTH]; + }; + struct Gathered { + std::vector ids; + std::vector installed; + } gathered; + // First gather everything, so we don't keep the ledger lock while calling callback() app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { - if ((manifest->flags & APP_MANIFEST_FLAG_HEADLESS) != 0 - && manifest->location.type == APP_LOCATION_MEMORY) { - auto& ids = *static_cast*>(context); - ids.emplace_back(); - memcpy(ids.back().id, manifest->id, sizeof(AppId)); + if ((manifest->flags & APP_MANIFEST_FLAG_HEADLESS) == 0) { + return; + } + auto& gathered = *static_cast(context); + if (manifest->location.type == APP_LOCATION_MEMORY) { + gathered.ids.emplace_back(); + memcpy(gathered.ids.back().id, manifest->id, sizeof(AppId)); + } else if (manifest->location.type == APP_LOCATION_PATH) { + // An installed app's location is its binary: /bin//.{elf,so} + const auto* path = static_cast(manifest->location.location); + const char* slash = strrchr(path, '/'); + const char* fileName = (slash != nullptr) ? slash + 1 : path; + const char* extension = strrchr(fileName, '.'); + if (extension == nullptr || (strcmp(extension, ".elf") != 0 && strcmp(extension, ".so") != 0)) { + return; + } + const auto nameLength = static_cast(extension - fileName); + InstalledCommand command; + if (nameLength == 0 || nameLength >= sizeof(command.name) || strlen(path) >= sizeof(command.path)) { + return; + } + memcpy(command.name, fileName, nameLength); + command.name[nameLength] = '\0'; + strcpy(command.path, path); + gathered.installed.push_back(command); } - }, &ids); - for (const auto& [id] : ids) { - callback(Command { .name = id, .help = "" }, context); + }, &gathered); + // Built-ins first, so they win a name clash with an installed app + for (const auto& [id] : gathered.ids) { + callback(Command { .name = id, .help = "", .path = nullptr }, context); + } + for (const auto& command : gathered.installed) { + callback(Command { .name = command.name, .help = "", .path = command.path }, context); } } @@ -239,22 +271,30 @@ int runCommand(int argc, char** argv, int* found) { // borrowed pointers into the manifest) and run after forEachCommand() returns, once // app_ledger()'s lock (held for the whole enumeration) is released: runFromMemory() starts a // real app instance by id, which itself needs the ledger. + // An installed app's binary path is copied, as forEachCommand()'s storage for it is gone by then. struct Match { const char* id; bool found; Command command; + char path[FILE_MAX_PATH_STRING_LENGTH]; }; - Match match { argv[0], false, {} }; + Match match { argv[0], false, {}, {} }; forEachCommand(&match, [](const Command& command, void* context) { auto* match = static_cast(context); if (!match->found && strcmp(command.name, match->id) == 0) { match->found = true; match->command = command; + if (command.path != nullptr) { + snprintf(match->path, sizeof(match->path), "%s", command.path); + } } }); if (match.found) { *found = 1; + if (match.command.path != nullptr) { + return runElf(match.path, argc, argv); + } return runFromMemory(argv[0], argc, argv); } diff --git a/Tactility/Tests/Source/ShellCompletionTest.cpp b/Tactility/Tests/Source/ShellCompletionTest.cpp index 2797c9da6..8f819f6ae 100644 --- a/Tactility/Tests/Source/ShellCompletionTest.cpp +++ b/Tactility/Tests/Source/ShellCompletionTest.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include @@ -50,3 +52,24 @@ TEST_CASE("shell completion: plain files complete when the word names a path") { // As an argument it completes too CHECK_EQ(complete("cat " + base + "/run"), "me.sh "); } + +TEST_CASE("shell completion: installed headless apps complete by their binary name") { + AppManifest headless {}; + snprintf(headless.id, sizeof(headless.id), "test.shell.installed"); + snprintf(headless.name, sizeof(headless.name), "Installed"); + headless.flags = APP_MANIFEST_FLAG_HEADLESS; + headless.location = { APP_LOCATION_PATH, const_cast("/nonexistent/bin/posix-x86_64/installedcmd.so") }; + REQUIRE_EQ(app_manager_add(&headless), ERROR_NONE); + + AppManifest windowed {}; + snprintf(windowed.id, sizeof(windowed.id), "test.shell.windowed"); + snprintf(windowed.name, sizeof(windowed.name), "Windowed"); + windowed.location = { APP_LOCATION_PATH, const_cast("/nonexistent/bin/posix-x86_64/windowedcmd.so") }; + REQUIRE_EQ(app_manager_add(&windowed), ERROR_NONE); + + CHECK_EQ(complete("installedc"), "md "); + CHECK_EQ(complete("windowedc"), ""); + + app_manager_remove("test.shell.windowed"); + app_manager_remove("test.shell.installed"); +} diff --git a/tactility.py b/tactility.py index c7db51b8f..7ace88ad1 100755 --- a/tactility.py +++ b/tactility.py @@ -953,6 +953,9 @@ def uninstall_action(manifest, arguments): # Anchor the cache to the invocation directory, before --path (below) can chdir into the app. ttbuild_path = os.path.abspath(ttbuild_path) + # Same for a relative local SDK path. + if os.environ.get("TACTILITY_SDK_PATH") is not None: + os.environ["TACTILITY_SDK_PATH"] = os.path.abspath(os.environ["TACTILITY_SDK_PATH"]) argv = sys.argv[1:] if len(argv) == 0: From acbb8b268526a253d9c534674996bf5e03d172eb Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 15:20:11 +0200 Subject: [PATCH 6/9] Fixes and improvements --- Modules/app-module/include/app/io.h | 3 +- .../private/app/private/stdio_wrap.h | 24 +++++-- Modules/app-module/source/io.cpp | 2 +- Modules/app-module/source/stdio_wrap.cpp | 64 ++++++++++++++++--- .../app-module/source/stdio_wrap_esp32.cpp | 16 +++-- .../app-module/source/stdio_wrap_posix.cpp | 16 +++-- Modules/app-module/tests/source/io_test.cpp | 49 ++++++++++++++ Modules/c-symbols-module/source/module.cpp | 2 + Tactility/Private/Tactility/app/shell/Run.h | 6 ++ Tactility/Private/Tactility/app/shell/Shell.h | 2 + Tactility/Source/app/shell/Run.cpp | 24 ++++++- Tactility/Source/app/shell/Shell.cpp | 24 ++++--- tactility.py | 17 ++++- 13 files changed, 205 insertions(+), 44 deletions(-) diff --git a/Modules/app-module/include/app/io.h b/Modules/app-module/include/app/io.h index d6a7d4324..fa9e56888 100644 --- a/Modules/app-module/include/app/io.h +++ b/Modules/app-module/include/app/io.h @@ -38,7 +38,8 @@ error_t app_io_await(int fd, AppFileWait wait, TickType_t timeout); /** * FD-table dispatch for AppFileOps::poll(): reports readiness without blocking. * @param[out] out_bits APP_FILE_READABLE and/or APP_FILE_WRITABLE - * @retval ERROR_NOT_FOUND @a fd isn't bound in the calling task's own app instance fd table + * @retval ERROR_NOT_FOUND @a fd isn't an app fd of the calling task's own app instance + * @retval ERROR_INVALID_STATE @a fd is an app fd that was already closed * @retval ERROR_NONE on success */ error_t app_io_poll(int fd, uint32_t* out_bits); diff --git a/Modules/app-module/private/app/private/stdio_wrap.h b/Modules/app-module/private/app/private/stdio_wrap.h index fdc5bb59d..37a02627f 100644 --- a/Modules/app-module/private/app/private/stdio_wrap.h +++ b/Modules/app-module/private/app/private/stdio_wrap.h @@ -32,12 +32,26 @@ bool tryAppGetCwd(char* buf, size_t size, char** out, int* outErrno); /** @return false if the caller isn't an app instance and should fall through to the real chdir() */ bool tryAppChdir(const char* path, int* outResult, int* outErrno); -bool isAppFd(int fd); +enum class AppFdState { + Bound, + /** An app fd that was already closed. Fails with EBADF rather than reaching a real fd of the same number. */ + Closed, + /** Not an app fd. Falls through to the real syscall. */ + NotAppFd, +}; -/** @return false if @a fd isn't app-bound and the caller should fall through to the real fstat() */ -bool tryAppFstat(int fd, struct stat* st); +AppFdState getAppFdState(int fd); -void fillAppTermios(struct termios* t); +/** + * The tryApp*() fd helpers below set *outResult (and errno on failure) when they return true. + * @return false if @a fd isn't an app fd and the caller should fall through to the real call + */ +bool tryAppFstat(int fd, struct stat* st, int* outResult); +bool tryAppTcgetattr(int fd, struct termios* t, int* outResult); +bool tryAppTcsetattr(int fd, int* outResult); -/** @return false if no fd in @a fds is app-bound and the caller should fall through to @a realPoll */ +/** + * Closed app fds report POLLNVAL. + * @return false if no fd in @a fds is an app fd and the caller should fall through to @a realPoll + */ bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realPoll, int* outResult); diff --git a/Modules/app-module/source/io.cpp b/Modules/app-module/source/io.cpp index a00515fcf..6c2fd57e4 100644 --- a/Modules/app-module/source/io.cpp +++ b/Modules/app-module/source/io.cpp @@ -154,7 +154,7 @@ error_t app_io_poll(int fd, uint32_t* out_bits) { } AppFile file {}; if (!app_fd_table_get_and_retain(table, fd, &file)) { - return ERROR_NOT_FOUND; + return app_fd_table_is_app_owned(table, fd) ? ERROR_INVALID_STATE : ERROR_NOT_FOUND; } *out_bits = file.ops->poll(file.object); if (file.ops->release != nullptr) { diff --git a/Modules/app-module/source/stdio_wrap.cpp b/Modules/app-module/source/stdio_wrap.cpp index e8049f1cc..eb33438e7 100644 --- a/Modules/app-module/source/stdio_wrap.cpp +++ b/Modules/app-module/source/stdio_wrap.cpp @@ -81,29 +81,68 @@ bool tryAppChdir(const char* path, int* outResult, int* outErrno) { return true; } -bool isAppFd(int fd) { +AppFdState getAppFdState(int fd) { uint32_t bits; - return app_io_poll(fd, &bits) == ERROR_NONE; + switch (app_io_poll(fd, &bits)) { + case ERROR_NONE: + return AppFdState::Bound; + case ERROR_INVALID_STATE: + return AppFdState::Closed; + default: + return AppFdState::NotAppFd; + } } // App fds report as character devices, which also makes isatty() true for them. -bool tryAppFstat(int fd, struct stat* st) { - if (st == nullptr || !isAppFd(fd)) { +bool tryAppFstat(int fd, struct stat* st, int* outResult) { + const AppFdState state = getAppFdState(fd); + if (state == AppFdState::NotAppFd) { return false; } + if (state == AppFdState::Closed) { + errno = EBADF; + *outResult = -1; + return true; + } memset(st, 0, sizeof(*st)); st->st_mode = S_IFCHR | 0666; + *outResult = 0; return true; } // App fd input is always raw and unechoed, and a written '\n' also returns the cursor. -// tcsetattr() accepts any settings without applying them. -void fillAppTermios(struct termios* t) { +bool tryAppTcgetattr(int fd, struct termios* t, int* outResult) { + const AppFdState state = getAppFdState(fd); + if (state == AppFdState::NotAppFd) { + return false; + } + if (state == AppFdState::Closed) { + errno = EBADF; + *outResult = -1; + return true; + } memset(t, 0, sizeof(*t)); t->c_oflag = OPOST | ONLCR; t->c_cflag = CS8 | CREAD; t->c_cc[VMIN] = 1; t->c_cc[VTIME] = 0; + *outResult = 0; + return true; +} + +// Accepts any settings without applying them. +bool tryAppTcsetattr(int fd, int* outResult) { + const AppFdState state = getAppFdState(fd); + if (state == AppFdState::NotAppFd) { + return false; + } + if (state == AppFdState::Closed) { + errno = EBADF; + *outResult = -1; + return true; + } + *outResult = 0; + return true; } namespace { @@ -118,18 +157,22 @@ bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realP if (fds == nullptr) { return false; } + // A closed app fd is always ready (POLLNVAL), so the wait below always has a bound fd to await int firstAppIndex = -1; + bool hasAppFd = false; nfds_t activeCount = 0; for (nfds_t i = 0; i < nfds; i++) { if (fds[i].fd < 0) { continue; } activeCount++; - if (firstAppIndex < 0 && isAppFd(fds[i].fd)) { + const AppFdState state = getAppFdState(fds[i].fd); + hasAppFd = hasAppFd || state != AppFdState::NotAppFd; + if (firstAppIndex < 0 && state == AppFdState::Bound) { firstAppIndex = static_cast(i); } } - if (firstAppIndex < 0) { + if (!hasAppFd) { return false; } @@ -143,13 +186,16 @@ bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realP continue; } uint32_t bits; - if (app_io_poll(fds[i].fd, &bits) == ERROR_NONE) { + const error_t pollResult = app_io_poll(fds[i].fd, &bits); + if (pollResult == ERROR_NONE) { if ((fds[i].events & POLLIN) && (bits & APP_FILE_READABLE)) { fds[i].revents |= POLLIN; } if ((fds[i].events & POLLOUT) && (bits & APP_FILE_WRITABLE)) { fds[i].revents |= POLLOUT; } + } else if (pollResult == ERROR_INVALID_STATE) { + fds[i].revents = POLLNVAL; } else { realPoll(&fds[i], 1, 0); } diff --git a/Modules/app-module/source/stdio_wrap_esp32.cpp b/Modules/app-module/source/stdio_wrap_esp32.cpp index 5a7a4f717..23bdcb034 100644 --- a/Modules/app-module/source/stdio_wrap_esp32.cpp +++ b/Modules/app-module/source/stdio_wrap_esp32.cpp @@ -77,8 +77,9 @@ int __wrap_chdir(const char* path) { int __real__fstat_r(struct _reent* r, int fd, struct stat* st); int __wrap__fstat_r(struct _reent* r, int fd, struct stat* st) { - if (tryAppFstat(fd, st)) { - return 0; + int result; + if (tryAppFstat(fd, st, &result)) { + return result; } return __real__fstat_r(r, fd, st); } @@ -97,16 +98,17 @@ int __real_tcgetattr(int fd, struct termios* p); int __real_tcsetattr(int fd, int optional_actions, const struct termios* p); int __wrap_tcgetattr(int fd, struct termios* p) { - if (isAppFd(fd)) { - fillAppTermios(p); - return 0; + int result; + if (tryAppTcgetattr(fd, p, &result)) { + return result; } return __real_tcgetattr(fd, p); } int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) { - if (isAppFd(fd)) { - return 0; + int result; + if (tryAppTcsetattr(fd, &result)) { + return result; } return __real_tcsetattr(fd, optional_actions, p); } diff --git a/Modules/app-module/source/stdio_wrap_posix.cpp b/Modules/app-module/source/stdio_wrap_posix.cpp index 2c8a6b8cd..399cc69c7 100644 --- a/Modules/app-module/source/stdio_wrap_posix.cpp +++ b/Modules/app-module/source/stdio_wrap_posix.cpp @@ -122,8 +122,9 @@ int __wrap_chdir(const char* path) { } int __wrap_fstat(int fd, struct stat* st) { - if (tryAppFstat(fd, st)) { - return 0; + int result; + if (tryAppFstat(fd, st, &result)) { + return result; } return __real_fstat(fd, st); } @@ -137,16 +138,17 @@ int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout) { } int __wrap_tcgetattr(int fd, struct termios* p) { - if (isAppFd(fd)) { - fillAppTermios(p); - return 0; + int result; + if (tryAppTcgetattr(fd, p, &result)) { + return result; } return __real_tcgetattr(fd, p); } int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) { - if (isAppFd(fd)) { - return 0; + int result; + if (tryAppTcsetattr(fd, &result)) { + return result; } return __real_tcsetattr(fd, optional_actions, p); } diff --git a/Modules/app-module/tests/source/io_test.cpp b/Modules/app-module/tests/source/io_test.cpp index 801c1c01d..7f071ecd1 100644 --- a/Modules/app-module/tests/source/io_test.cpp +++ b/Modules/app-module/tests/source/io_test.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -230,8 +231,56 @@ int32_t posix_calls_app_main(int, char*[]) { return 0; } +std::atomic g_closed_fstat_ebadf { false }; +std::atomic g_closed_tcgetattr_ebadf { false }; +std::atomic g_closed_tcsetattr_ebadf { false }; +std::atomic g_closed_poll_result { -2 }; +std::atomic g_closed_poll_revents { -2 }; + +// Must never reach the process's real fd 0 (the host terminal on the simulator). +int32_t closed_stdin_posix_calls_app_main(int, char*[]) { + app_io_close(STDIN_FILENO); + + struct stat st {}; + g_closed_fstat_ebadf.store(fstat(STDIN_FILENO, &st) == -1 && errno == EBADF, std::memory_order_release); + struct termios t {}; + g_closed_tcgetattr_ebadf.store(tcgetattr(STDIN_FILENO, &t) == -1 && errno == EBADF, std::memory_order_release); + g_closed_tcsetattr_ebadf.store(tcsetattr(STDIN_FILENO, TCSANOW, &t) == -1 && errno == EBADF, std::memory_order_release); + + struct pollfd fds { STDIN_FILENO, POLLIN, 0 }; + g_closed_poll_result.store(poll(&fds, 1, 1000), std::memory_order_release); + g_closed_poll_revents.store(fds.revents, std::memory_order_release); + return 0; +} + } // namespace +TEST_CASE("fstat, termios and poll on a closed app stdin fail with EBADF/POLLNVAL instead of reaching the real fd") { + ensure_memory_loader_registered(); + g_closed_fstat_ebadf.store(false, std::memory_order_relaxed); + g_closed_tcgetattr_ebadf.store(false, std::memory_order_relaxed); + g_closed_tcsetattr_ebadf.store(false, std::memory_order_relaxed); + g_closed_poll_result.store(-2, std::memory_order_relaxed); + g_closed_poll_revents.store(-2, std::memory_order_relaxed); + + AppManifest manifest { "test.io.closed_posix_calls", "ClosedPosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(closed_stdin_posix_calls_app_main) } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + AppInstanceId instance_id = 0; + AppStartContext context; + REQUIRE_EQ(app_start_context_from_id("test.io.closed_posix_calls", &context), ERROR_NONE); + REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE); + REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000)); + + CHECK(g_closed_fstat_ebadf.load(std::memory_order_acquire)); + CHECK(g_closed_tcgetattr_ebadf.load(std::memory_order_acquire)); + CHECK(g_closed_tcsetattr_ebadf.load(std::memory_order_acquire)); + CHECK_EQ(g_closed_poll_result.load(std::memory_order_acquire), 1); + CHECK_EQ(g_closed_poll_revents.load(std::memory_order_acquire), POLLNVAL); + + app_manager_remove("test.io.closed_posix_calls"); +} + TEST_CASE("fstat, termios and poll on a bound app stdin report a raw character device that becomes readable") { ensure_memory_loader_registered(); g_posix_stdin_is_char_device.store(false, std::memory_order_relaxed); diff --git a/Modules/c-symbols-module/source/module.cpp b/Modules/c-symbols-module/source/module.cpp index a6b57459f..84cf6d705 100644 --- a/Modules/c-symbols-module/source/module.cpp +++ b/Modules/c-symbols-module/source/module.cpp @@ -148,7 +148,9 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL_SIGNATURE(strrchr, const char* (*)(const char*, int)), DEFINE_MODULE_SYMBOL_SIGNATURE(strpbrk, const char* (*)(const char*, const char*)), DEFINE_MODULE_SYMBOL_SIGNATURE(memchr, const void* (*)(const void*, int, size_t)), +#if !defined(__APPLE__) DEFINE_MODULE_SYMBOL_SIGNATURE(memrchr, const void* (*)(const void*, int, size_t)), +#endif DEFINE_MODULE_SYMBOL(strerror), DEFINE_MODULE_SYMBOL(strtod), DEFINE_MODULE_SYMBOL(strtol), diff --git a/Tactility/Private/Tactility/app/shell/Run.h b/Tactility/Private/Tactility/app/shell/Run.h index d400c5c91..ca41f8654 100644 --- a/Tactility/Private/Tactility/app/shell/Run.h +++ b/Tactility/Private/Tactility/app/shell/Run.h @@ -17,6 +17,12 @@ int runScript(const char* resolvedPath, int argc, char** argv); */ int runElf(const char* resolvedPath, int argc, char** argv); +/** + * Runs an installed app's binary like runElf(), but started from its registered manifest @a id, + * so it gets the stack and memory configuration that manifest declares. + */ +int runInstalled(const char* id, const char* resolvedPath, int argc, char** argv); + /** * Runs a registered, in-memory app (APP_LOCATION_MEMORY) as its own app instance, the same way * runElf() runs a loaded binary as one: its own task and fd table, stdio piped through this app's diff --git a/Tactility/Private/Tactility/app/shell/Shell.h b/Tactility/Private/Tactility/app/shell/Shell.h index 2b46db1ad..fc5c04374 100644 --- a/Tactility/Private/Tactility/app/shell/Shell.h +++ b/Tactility/Private/Tactility/app/shell/Shell.h @@ -14,6 +14,8 @@ namespace Shell { struct Command { const char* name; const char* help; + /** The app manifest this command starts. Equals name for a built-in app. */ + const char* id; /** Binary to run for an installed app, nullptr for a built-in one. */ const char* path; }; diff --git a/Tactility/Source/app/shell/Run.cpp b/Tactility/Source/app/shell/Run.cpp index 996825a81..6f05e7a1e 100644 --- a/Tactility/Source/app/shell/Run.cpp +++ b/Tactility/Source/app/shell/Run.cpp @@ -267,7 +267,11 @@ void endLineIfNeeded() { } } -int runElf(const char* resolvedPath, int argc, char** argv) { +namespace { + +// Shared by runElf() and runInstalled(): only how @a context is initialized differs, a manifest's +// stack/memory config for a registered app vs. none for a plain binary. +int runBinary(AppStartContext context, const char* resolvedPath, int argc, char** argv) { // Relative-looking arguments are made absolute before the binary sees them: ESP-IDF has no // per-process cwd for fopen() to resolve against (no chdir() at all), and the shell's own cwd // lives in ShellFs, meaning nothing to libc. shouldMakeAbsolute() decides which arguments qualify. @@ -313,8 +317,6 @@ int runElf(const char* resolvedPath, int argc, char** argv) { // until the binary happens to exit. fflush(stdout); - AppLocation location { APP_LOCATION_PATH, const_cast(resolvedPath) }; - AppStartContext context = app_start_context_for_location(location); app_start_context_set_arguments_ext(&context, passedArgc, rewritten); const int result = runApp(context); @@ -334,6 +336,22 @@ int runElf(const char* resolvedPath, int argc, char** argv) { return exitCode; } +} // namespace + +int runElf(const char* resolvedPath, int argc, char** argv) { + AppLocation location { APP_LOCATION_PATH, const_cast(resolvedPath) }; + return runBinary(app_start_context_for_location(location), resolvedPath, argc, argv); +} + +int runInstalled(const char* id, const char* resolvedPath, int argc, char** argv) { + AppStartContext context; + if (app_start_context_from_id(id, &context) != ERROR_NONE) { + printf("%s: not found\n", argv[0]); + return 127; + } + return runBinary(context, resolvedPath, argc, argv); +} + int runFromMemory(const char* id, int argc, char** argv) { AppStartContext context; if (app_start_context_from_id(id, &context) != ERROR_NONE) { diff --git a/Tactility/Source/app/shell/Shell.cpp b/Tactility/Source/app/shell/Shell.cpp index 16d0ab7a8..850d7fea1 100644 --- a/Tactility/Source/app/shell/Shell.cpp +++ b/Tactility/Source/app/shell/Shell.cpp @@ -68,6 +68,7 @@ void forEachCommand(void* context, void (*callback)(const Command&, void*)) { struct CommandId { AppId id; }; struct InstalledCommand { char name[APP_MANIFEST_BINARY_LENGTH + 1]; + AppId id; char path[FILE_MAX_PATH_STRING_LENGTH]; }; struct Gathered { @@ -99,16 +100,17 @@ void forEachCommand(void* context, void (*callback)(const Command&, void*)) { } memcpy(command.name, fileName, nameLength); command.name[nameLength] = '\0'; + memcpy(command.id, manifest->id, sizeof(AppId)); strcpy(command.path, path); gathered.installed.push_back(command); } }, &gathered); // Built-ins first, so they win a name clash with an installed app for (const auto& [id] : gathered.ids) { - callback(Command { .name = id, .help = "", .path = nullptr }, context); + callback(Command { .name = id, .help = "", .id = id, .path = nullptr }, context); } for (const auto& command : gathered.installed) { - callback(Command { .name = command.name, .help = "", .path = command.path }, context); + callback(Command { .name = command.name, .help = "", .id = command.id, .path = command.path }, context); } } @@ -271,20 +273,22 @@ int runCommand(int argc, char** argv, int* found) { // borrowed pointers into the manifest) and run after forEachCommand() returns, once // app_ledger()'s lock (held for the whole enumeration) is released: runFromMemory() starts a // real app instance by id, which itself needs the ledger. - // An installed app's binary path is copied, as forEachCommand()'s storage for it is gone by then. + // An installed app's id and binary path are copied, as forEachCommand()'s storage for them is gone by then. struct Match { - const char* id; + const char* name; bool found; - Command command; + bool installed; + AppId id; char path[FILE_MAX_PATH_STRING_LENGTH]; }; - Match match { argv[0], false, {}, {} }; + Match match { argv[0], false, false, {}, {} }; forEachCommand(&match, [](const Command& command, void* context) { auto* match = static_cast(context); - if (!match->found && strcmp(command.name, match->id) == 0) { + if (!match->found && strcmp(command.name, match->name) == 0) { match->found = true; - match->command = command; if (command.path != nullptr) { + match->installed = true; + snprintf(match->id, sizeof(match->id), "%s", command.id); snprintf(match->path, sizeof(match->path), "%s", command.path); } } @@ -292,8 +296,8 @@ int runCommand(int argc, char** argv, int* found) { if (match.found) { *found = 1; - if (match.command.path != nullptr) { - return runElf(match.path, argc, argv); + if (match.installed) { + return runInstalled(match.id, match.path, argc, argv); } return runFromMemory(argv[0], argc, argv); } diff --git a/tactility.py b/tactility.py index 7ace88ad1..1ade7d8e1 100755 --- a/tactility.py +++ b/tactility.py @@ -309,6 +309,8 @@ def validate_local_sdks(platforms, version): sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") if not os.path.isdir(sdk_dir): exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}") + if not sdk_has_sdkconfig(version, platform): + exit_with_error(f"Local SDK for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Rebuild it with the current SDK release scripts.") def get_sdk_root_dir(version, platform): global ttbuild_cdn @@ -338,8 +340,15 @@ def update_tool_json(version): os.makedirs(os.path.dirname(json_filepath), exist_ok=True) return download_file(get_sdk_url(version, "tactility.py.json"), json_filepath) +def get_sdk_sdkconfig_path(version, platform): + return os.path.join(get_sdk_dir(version, platform), f"sdkconfig.app.{platform}") + +# ESP32 SDKs built before the sdkconfig was bundled lack it and can't build apps. POSIX SDKs never need one. +def sdk_has_sdkconfig(version, platform): + return platform.startswith("posix") or os.path.isfile(get_sdk_sdkconfig_path(version, platform)) + def copy_sdk_sdkconfig(version, platform): - sdkconfig_path = os.path.join(get_sdk_dir(version, platform), f"sdkconfig.app.{platform}") + sdkconfig_path = get_sdk_sdkconfig_path(version, platform) if not os.path.isfile(sdkconfig_path): exit_with_error(f"SDK does not contain {sdkconfig_path}") shutil.copy(sdkconfig_path, "sdkconfig") @@ -501,9 +510,15 @@ def sdk_download(version, platform): def sdk_download_all(version, platforms): for platform in platforms: + if sdk_exists(version, platform) and not sdk_has_sdkconfig(version, platform): + print_warning(f"Cached SDK version {version} for {platform} is outdated (no sdkconfig.app.{platform}), downloading it again") + shutil.rmtree(get_sdk_root_dir(version, platform)) if not sdk_exists(version, platform): if not sdk_download(version, platform): return False + if not sdk_has_sdkconfig(version, platform): + print_error(f"SDK version {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.") + return False else: if verbose: print(f"Using cached download for SDK version {version} and platform {platform}") From 728a6aa68ffec3067d5cbb455d727701d868c723 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 16:00:46 +0200 Subject: [PATCH 7/9] Fixes and improvements --- CMakeLists.txt | 3 ++ Modules/app-module/include/app/scheduler.h | 7 ++++ .../private/app/private/stdio_wrap_posix.h | 1 + Modules/app-module/source/scheduler.cpp | 32 ++++++++++++++++++- .../app-module/source/stdio_wrap_apple.cpp | 2 ++ Modules/app-module/source/stdio_wrap_elf.cpp | 4 +++ .../app-module/source/stdio_wrap_esp32.cpp | 9 ++++++ .../app-module/source/stdio_wrap_posix.cpp | 13 ++++++++ Modules/app-module/tests/source/io_test.cpp | 31 ++++++++++++++++++ 9 files changed, 101 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 307663efa..051bb1de5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,9 @@ if (DEFINED ENV{ESP_IDF_VERSION}) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=tcgetattr" APPEND) idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=tcsetattr" APPEND) + # exit() from an app ends only that app (see app_scheduler_exit_current()) instead of aborting. + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=exit" APPEND) + # opendir()/readdir()/closedir() also define the plain names directly (vfs_calls.c). # Wrapped so opendir("/") synthesizes a listing of own registered filesystems instead of ENOENT. # See Platforms/platform-esp32/source/root_dir.cpp. diff --git a/Modules/app-module/include/app/scheduler.h b/Modules/app-module/include/app/scheduler.h index 163c44ec9..d2e9ee6c5 100644 --- a/Modules/app-module/include/app/scheduler.h +++ b/Modules/app-module/include/app/scheduler.h @@ -16,6 +16,13 @@ extern "C" { */ AppInstanceId app_scheduler_current_app_id(void); +/** + * Ends the calling app instance as if its main() returned @a status. Like C's exit(), the app's + * stack is not unwound (no C++ destructors) and memory it allocated is not freed. + * Returns only when not called from an app instance's own main task. + */ +void app_scheduler_exit_current(int32_t status); + #ifdef __cplusplus } #endif diff --git a/Modules/app-module/private/app/private/stdio_wrap_posix.h b/Modules/app-module/private/app/private/stdio_wrap_posix.h index 45afb911b..a54aeb262 100644 --- a/Modules/app-module/private/app/private/stdio_wrap_posix.h +++ b/Modules/app-module/private/app/private/stdio_wrap_posix.h @@ -22,6 +22,7 @@ int __wrap_fstat(int fd, struct stat* st); int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout); int __wrap_tcgetattr(int fd, struct termios* p); int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p); +[[noreturn]] void __wrap_exit(int status); int __wrap_vprintf(const char* format, va_list args); int __wrap_printf(const char* format, ...); diff --git a/Modules/app-module/source/scheduler.cpp b/Modules/app-module/source/scheduler.cpp index 80053b808..4b3c72399 100644 --- a/Modules/app-module/source/scheduler.cpp +++ b/Modules/app-module/source/scheduler.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -96,6 +97,16 @@ void set_state(AppInstanceId app_instance_id, AppInstanceState state) { mutex_unlock(&ledger.mutex); } +// Where app_scheduler_exit_current() returns to in app_task_main(), with the exit status. +struct AppExitPoint { + jmp_buf jump; + int32_t status; +}; + +// Per thread rather than looked up by app_scheduler_current_app_id(): on the simulator, that reads +// the scheduled FreeRTOS task even from a foreign thread, which must never jump into an app's stack. +thread_local AppExitPoint* current_exit_point = nullptr; + void set_task(AppInstanceId app_instance_id, TaskHandle_t task) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); @@ -207,7 +218,16 @@ void app_task_main(void* context) { set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE); - int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, app_arguments_count_null_terminated(ctx->argv), ctx->argv); + // exit() called on this task returns here instead of ending the whole process + AppExitPoint exit_point {}; + current_exit_point = &exit_point; + int32_t result; + if (setjmp(exit_point.jump) == 0) { + result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, app_arguments_count_null_terminated(ctx->argv), ctx->argv); + } else { + result = exit_point.status; + } + current_exit_point = nullptr; // The platform might buffer stdout (e.g. esp-idf with newlib) // Do a manual flush to ensure data has been written: @@ -458,4 +478,14 @@ AppInstanceId app_scheduler_current_app_id(void) { return reinterpret_cast(value); } +void app_scheduler_exit_current(int32_t status) { + AppExitPoint* exit_point = current_exit_point; + if (exit_point == nullptr) { + return; + } + fflush(stdout); + exit_point->status = status; + longjmp(exit_point->jump, 1); +} + } // extern "C" diff --git a/Modules/app-module/source/stdio_wrap_apple.cpp b/Modules/app-module/source/stdio_wrap_apple.cpp index 427546e45..693cd5f66 100644 --- a/Modules/app-module/source/stdio_wrap_apple.cpp +++ b/Modules/app-module/source/stdio_wrap_apple.cpp @@ -3,6 +3,7 @@ #include +#include #include #include @@ -23,6 +24,7 @@ TT_DYLD_INTERPOSE(__wrap_fstat, fstat) TT_DYLD_INTERPOSE(__wrap_poll, poll) TT_DYLD_INTERPOSE(__wrap_tcgetattr, tcgetattr) TT_DYLD_INTERPOSE(__wrap_tcsetattr, tcsetattr) +TT_DYLD_INTERPOSE(__wrap_exit, exit) TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf) TT_DYLD_INTERPOSE(__wrap_printf, printf) diff --git a/Modules/app-module/source/stdio_wrap_elf.cpp b/Modules/app-module/source/stdio_wrap_elf.cpp index 449ce53df..4c0375b13 100644 --- a/Modules/app-module/source/stdio_wrap_elf.cpp +++ b/Modules/app-module/source/stdio_wrap_elf.cpp @@ -51,6 +51,10 @@ int tcsetattr(int fd, int optional_actions, const struct termios* p) { return __wrap_tcsetattr(fd, optional_actions, p); } +void exit(int status) { + __wrap_exit(status); +} + int vprintf(const char* format, va_list args) { return __wrap_vprintf(format, args); } diff --git a/Modules/app-module/source/stdio_wrap_esp32.cpp b/Modules/app-module/source/stdio_wrap_esp32.cpp index 23bdcb034..0f42c4af1 100644 --- a/Modules/app-module/source/stdio_wrap_esp32.cpp +++ b/Modules/app-module/source/stdio_wrap_esp32.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -113,6 +114,14 @@ int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) { return __real_tcsetattr(fd, optional_actions, p); } +// Called by an app, newlib's exit() would reach _exit(), which aborts the whole device +[[noreturn]] void __real_exit(int status); + +[[noreturn]] void __wrap_exit(int status) { + app_scheduler_exit_current(status); + __real_exit(status); +} + } #endif // ESP_PLATFORM diff --git a/Modules/app-module/source/stdio_wrap_posix.cpp b/Modules/app-module/source/stdio_wrap_posix.cpp index 399cc69c7..24b0d8181 100644 --- a/Modules/app-module/source/stdio_wrap_posix.cpp +++ b/Modules/app-module/source/stdio_wrap_posix.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -71,6 +72,12 @@ int __real_tcsetattr(int fd, int optional_actions, const struct termios* p) { return real(fd, optional_actions, p); } +[[noreturn]] void __real_exit(int status) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "exit")); + real(status); + __builtin_unreachable(); +} + ssize_t __wrap_read(int fd, void* buffer, size_t size) { return app_io_read(fd, buffer, size); } @@ -153,6 +160,12 @@ int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) { return __real_tcsetattr(fd, optional_actions, p); } +// Called by an app, the real exit() would end the whole simulator +void __wrap_exit(int status) { + app_scheduler_exit_current(status); + __real_exit(status); +} + } // endregion diff --git a/Modules/app-module/tests/source/io_test.cpp b/Modules/app-module/tests/source/io_test.cpp index 7f071ecd1..93ab95a96 100644 --- a/Modules/app-module/tests/source/io_test.cpp +++ b/Modules/app-module/tests/source/io_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -253,8 +254,38 @@ int32_t closed_stdin_posix_calls_app_main(int, char*[]) { return 0; } +std::atomic g_exit_before { false }; +std::atomic g_exit_after { false }; + +int32_t exit_app_main(int, char*[]) { + g_exit_before.store(true, std::memory_order_release); + exit(42); + g_exit_after.store(true, std::memory_order_release); + return 0; +} + } // namespace +TEST_CASE("exit() in an app ends only that app instance") { + ensure_memory_loader_registered(); + g_exit_before.store(false, std::memory_order_relaxed); + g_exit_after.store(false, std::memory_order_relaxed); + + AppManifest manifest { "test.io.exit", "Exit", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(exit_app_main) } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + AppInstanceId instance_id = 0; + AppStartContext context; + REQUIRE_EQ(app_start_context_from_id("test.io.exit", &context), ERROR_NONE); + REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE); + REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000)); + + CHECK(g_exit_before.load(std::memory_order_acquire)); + CHECK_FALSE(g_exit_after.load(std::memory_order_acquire)); + + app_manager_remove("test.io.exit"); +} + TEST_CASE("fstat, termios and poll on a closed app stdin fail with EBADF/POLLNVAL instead of reaching the real fd") { ensure_memory_loader_registered(); g_closed_fstat_ebadf.store(false, std::memory_order_relaxed); From d1a37a1ae9754be915477fb2b452c7d9d637af7b Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 16:37:47 +0200 Subject: [PATCH 8/9] Fixes and improvements --- Documentation/ideas.md | 3 + Modules/app-module/include/app/scheduler.h | 5 +- Modules/app-module/source/scheduler.cpp | 92 ++++++++++++++----- Modules/app-module/source/stdio_wrap.cpp | 10 ++ Modules/app-module/tests/source/io_test.cpp | 9 ++ Modules/app-posix-module/tests/CMakeLists.txt | 7 +- .../tests/fixtures/exit_fixture.cpp | 11 +++ .../tests/source/loader_test.cpp | 58 ++++++++++++ Tactility/Source/app/shell/Run.cpp | 2 + tactility.py | 2 + 10 files changed, 173 insertions(+), 26 deletions(-) create mode 100644 Modules/app-posix-module/tests/fixtures/exit_fixture.cpp diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 75c6f3991..eb633a905 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -23,6 +23,9 @@ ## Medium Priority - Implement signal/raise for terminal apps (for CTRL+C and window resize signals) +- Build the simulator with `-fno-exceptions` to match ESP32. First make app exit() work without C++ unwinding there: + scheduler.cpp's DeferredUnload relies on pthread_exit()'s forced unwind running its destructor. + Without exceptions, an app that calls exit() is never unloaded and the next app start waits the full timeout. - netutils-module: ping, wget, ifconfig, nslookup, etc. - Core2: support power off via software - Improve Setup: Show "Step done" screen diff --git a/Modules/app-module/include/app/scheduler.h b/Modules/app-module/include/app/scheduler.h index d2e9ee6c5..f45d08426 100644 --- a/Modules/app-module/include/app/scheduler.h +++ b/Modules/app-module/include/app/scheduler.h @@ -17,8 +17,9 @@ extern "C" { AppInstanceId app_scheduler_current_app_id(void); /** - * Ends the calling app instance as if its main() returned @a status. Like C's exit(), the app's - * stack is not unwound (no C++ destructors) and memory it allocated is not freed. + * Ends the calling app instance as if its main() returned @a status, by ending its task in place. + * The calling frames are never resumed. Like std::exit(), automatic objects are not destroyed + * (the simulator's thread exit may still destroy them). Memory the app allocated is not freed. * Returns only when not called from an app instance's own main task. */ void app_scheduler_exit_current(int32_t status); diff --git a/Modules/app-module/source/scheduler.cpp b/Modules/app-module/source/scheduler.cpp index 4b3c72399..f7ed27bbe 100644 --- a/Modules/app-module/source/scheduler.cpp +++ b/Modules/app-module/source/scheduler.cpp @@ -15,9 +15,11 @@ #include #include +#include #include +#include -#include +#include #include #include #include @@ -97,15 +99,42 @@ void set_state(AppInstanceId app_instance_id, AppInstanceState state) { mutex_unlock(&ledger.mutex); } -// Where app_scheduler_exit_current() returns to in app_task_main(), with the exit status. -struct AppExitPoint { - jmp_buf jump; - int32_t status; +// Set while this thread is an app instance's task running AppLoaderApi::run(). Per thread rather than +// looked up by app_scheduler_current_app_id(): on the simulator, that reads the scheduled FreeRTOS task +// even from a foreign thread, which must never end an app's task. +thread_local TaskContext* current_task_context = nullptr; + +#ifndef ESP_PLATFORM +// On the simulator, a task ending itself unwinds its whole stack (pthread_exit()). After exit(), that +// stack still holds the app's own frames, so its binary may only be unloaded once the unwind has +// passed them: this guard lives in app_task_main()'s frame and unloads when the unwind destroys it. +struct DeferredUnload { + const AppLoaderApi* loader = nullptr; + void* runtime = nullptr; + + ~DeferredUnload() { + if (loader != nullptr) { + loader->unload(runtime); + pending_deferred_unloads.fetch_sub(1, std::memory_order_release); + } + } + + // A binary that is still loaded would be reused by the next load of the same path, globals included + static inline std::atomic pending_deferred_unloads { 0 }; }; -// Per thread rather than looked up by app_scheduler_current_app_id(): on the simulator, that reads -// the scheduled FreeRTOS task even from a foreign thread, which must never jump into an app's stack. -thread_local AppExitPoint* current_exit_point = nullptr; +thread_local DeferredUnload* current_deferred_unload = nullptr; + +constexpr TickType_t DEFERRED_UNLOAD_WAIT_TICKS = pdMS_TO_TICKS(1000); + +void wait_for_deferred_unloads() { + const TickType_t start = get_ticks(); + while (DeferredUnload::pending_deferred_unloads.load(std::memory_order_acquire) > 0 + && get_timeout_remaining_ticks(DEFERRED_UNLOAD_WAIT_TICKS, start) > 0) { + delay_ticks(1); + } +} +#endif void set_task(AppInstanceId app_instance_id, TaskHandle_t task) { auto& ledger = app_ledger(); @@ -205,8 +234,14 @@ void deliver_result_to_parent_if_any(AppInstanceId app_instance_id, int32_t resu } } +void finish_app_task(TaskContext* ctx, int32_t result, bool exiting); + void app_task_main(void* context) { auto* ctx = static_cast(context); +#ifndef ESP_PLATFORM + DeferredUnload deferred_unload; + current_deferred_unload = &deferred_unload; +#endif check(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX) == nullptr); vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, reinterpret_cast(static_cast(ctx->app_instance_id))); @@ -218,24 +253,33 @@ void app_task_main(void* context) { set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE); - // exit() called on this task returns here instead of ending the whole process - AppExitPoint exit_point {}; - current_exit_point = &exit_point; - int32_t result; - if (setjmp(exit_point.jump) == 0) { - result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, app_arguments_count_null_terminated(ctx->argv), ctx->argv); - } else { - result = exit_point.status; - } - current_exit_point = nullptr; + current_task_context = ctx; + int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, app_arguments_count_null_terminated(ctx->argv), ctx->argv); + current_task_context = nullptr; + finish_app_task(ctx, result, false); +} + +// Everything after an app's AppLoaderApi::run() returned, or after it called exit(). Ends the calling task. +void finish_app_task(TaskContext* ctx, int32_t result, bool exiting) { // The platform might buffer stdout (e.g. esp-idf with newlib) // Do a manual flush to ensure data has been written: fflush(stdout); vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, nullptr); +#ifndef ESP_PLATFORM + if (exiting) { + current_deferred_unload->loader = ctx->loader; + current_deferred_unload->runtime = ctx->runtime; + DeferredUnload::pending_deferred_unloads.fetch_add(1, std::memory_order_release); + } else { + ctx->loader->unload(ctx->runtime); + } +#else + (void)exiting; ctx->loader->unload(ctx->runtime); +#endif deliver_result_to_parent_if_any(ctx->app_instance_id, result); @@ -303,6 +347,9 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, const AppStartContext } void* runtime = nullptr; +#ifndef ESP_PLATFORM + wait_for_deferred_unloads(); +#endif error_t load_result = loader->load(location, &runtime); if (load_result != ERROR_NONE) { LOG_E(TAG, "[instance %lu] Failed to load app: %s", app_instance_id, error_to_string(load_result)); @@ -479,13 +526,12 @@ AppInstanceId app_scheduler_current_app_id(void) { } void app_scheduler_exit_current(int32_t status) { - AppExitPoint* exit_point = current_exit_point; - if (exit_point == nullptr) { + TaskContext* ctx = current_task_context; + if (ctx == nullptr) { return; } - fflush(stdout); - exit_point->status = status; - longjmp(exit_point->jump, 1); + current_task_context = nullptr; + finish_app_task(ctx, status, true); } } // extern "C" diff --git a/Modules/app-module/source/stdio_wrap.cpp b/Modules/app-module/source/stdio_wrap.cpp index eb33438e7..5a431d4d1 100644 --- a/Modules/app-module/source/stdio_wrap.cpp +++ b/Modules/app-module/source/stdio_wrap.cpp @@ -104,6 +104,11 @@ bool tryAppFstat(int fd, struct stat* st, int* outResult) { *outResult = -1; return true; } + if (st == nullptr) { + errno = EFAULT; + *outResult = -1; + return true; + } memset(st, 0, sizeof(*st)); st->st_mode = S_IFCHR | 0666; *outResult = 0; @@ -121,6 +126,11 @@ bool tryAppTcgetattr(int fd, struct termios* t, int* outResult) { *outResult = -1; return true; } + if (t == nullptr) { + errno = EFAULT; + *outResult = -1; + return true; + } memset(t, 0, sizeof(*t)); t->c_oflag = OPOST | ONLCR; t->c_cflag = CS8 | CREAD; diff --git a/Modules/app-module/tests/source/io_test.cpp b/Modules/app-module/tests/source/io_test.cpp index 93ab95a96..b787ba674 100644 --- a/Modules/app-module/tests/source/io_test.cpp +++ b/Modules/app-module/tests/source/io_test.cpp @@ -215,6 +215,7 @@ std::atomic g_posix_tcsetattr_result { -2 }; std::atomic g_posix_poll_before_write { -2 }; std::atomic g_posix_poll_after_write { -2 }; std::atomic g_posix_poll_first_done { false }; +std::atomic g_posix_null_buffers_efault { false }; int32_t posix_calls_app_main(int, char*[]) { struct stat st {}; @@ -225,6 +226,12 @@ int32_t posix_calls_app_main(int, char*[]) { g_posix_stdin_is_raw.store((t.c_lflag & (ICANON | ECHO)) == 0, std::memory_order_release); g_posix_tcsetattr_result.store(tcsetattr(STDIN_FILENO, TCSANOW, &t), std::memory_order_release); + struct stat* volatile null_stat = nullptr; + struct termios* volatile null_termios = nullptr; + const bool fstat_efault = fstat(STDIN_FILENO, null_stat) == -1 && errno == EFAULT; + const bool tcgetattr_efault = tcgetattr(STDIN_FILENO, null_termios) == -1 && errno == EFAULT; + g_posix_null_buffers_efault.store(fstat_efault && tcgetattr_efault, std::memory_order_release); + struct pollfd fds { STDIN_FILENO, POLLIN, 0 }; g_posix_poll_before_write.store(poll(&fds, 1, 50), std::memory_order_release); g_posix_poll_first_done.store(true, std::memory_order_release); @@ -321,6 +328,7 @@ TEST_CASE("fstat, termios and poll on a bound app stdin report a raw character d g_posix_poll_before_write.store(-2, std::memory_order_relaxed); g_posix_poll_after_write.store(-2, std::memory_order_relaxed); g_posix_poll_first_done.store(false, std::memory_order_relaxed); + g_posix_null_buffers_efault.store(false, std::memory_order_relaxed); AppManifest manifest { "test.io.posix_calls", "PosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(posix_calls_app_main) } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); @@ -345,6 +353,7 @@ TEST_CASE("fstat, termios and poll on a bound app stdin report a raw character d CHECK_EQ(g_posix_tcgetattr_result.load(std::memory_order_acquire), 0); CHECK(g_posix_stdin_is_raw.load(std::memory_order_acquire)); CHECK_EQ(g_posix_tcsetattr_result.load(std::memory_order_acquire), 0); + CHECK(g_posix_null_buffers_efault.load(std::memory_order_acquire)); CHECK_EQ(g_posix_poll_before_write.load(std::memory_order_acquire), 0); CHECK_EQ(g_posix_poll_after_write.load(std::memory_order_acquire), 1); diff --git a/Modules/app-posix-module/tests/CMakeLists.txt b/Modules/app-posix-module/tests/CMakeLists.txt index 6de21b0e6..df85980c4 100644 --- a/Modules/app-posix-module/tests/CMakeLists.txt +++ b/Modules/app-posix-module/tests/CMakeLists.txt @@ -18,6 +18,10 @@ set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPEND add_library(printf_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/fixtures/printf_fixture.cpp) set_target_properties(printf_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON) +# Fixture: calls exit() with a live std::string on its stack, resolving exit() against the host's override. +add_library(exit_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/fixtures/exit_fixture.cpp) +set_target_properties(exit_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON) + # A file with a ".so" extension but no ELF header, for is_executable() rejection tests. set(NON_ELF_FIXTURE_PATH "${CMAKE_CURRENT_BINARY_DIR}/not-elf.so") file(WRITE "${NON_ELF_FIXTURE_PATH}" "not an elf file") @@ -36,12 +40,13 @@ add_custom_target(install_dir_fixture DEPENDS "${INSTALL_DIR_FIXTURE_BIN_DIR}/ap file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) add_executable(AppPosixModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) -add_dependencies(AppPosixModuleTests app_posix_module_test_fixture printf_fixture install_dir_fixture) +add_dependencies(AppPosixModuleTests app_posix_module_test_fixture printf_fixture exit_fixture install_dir_fixture) target_include_directories(AppPosixModuleTests PRIVATE ${DOCTESTINC}) target_compile_definitions(AppPosixModuleTests PRIVATE FIXTURE_APP_PATH="$" PRINTF_FIXTURE_APP_PATH="$" + EXIT_FIXTURE_APP_PATH="$" FIXTURE_NON_ELF_PATH="${NON_ELF_FIXTURE_PATH}" FIXTURE_INSTALL_DIR_PATH="${INSTALL_DIR_FIXTURE_PATH}" ) diff --git a/Modules/app-posix-module/tests/fixtures/exit_fixture.cpp b/Modules/app-posix-module/tests/fixtures/exit_fixture.cpp new file mode 100644 index 000000000..e450448c7 --- /dev/null +++ b/Modules/app-posix-module/tests/fixtures/exit_fixture.cpp @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +// Deliberately not linked against app-module: exit() must resolve against Tactility's own override +// in the loading process. The live std::string makes sure ending the task with it on the stack is safe. +extern "C" int32_t main(int, char*[]) { + std::string live = "still on the stack"; + exit(static_cast(live.size())); +} diff --git a/Modules/app-posix-module/tests/source/loader_test.cpp b/Modules/app-posix-module/tests/source/loader_test.cpp index 4654cf29b..8ab0e08c2 100644 --- a/Modules/app-posix-module/tests/source/loader_test.cpp +++ b/Modules/app-posix-module/tests/source/loader_test.cpp @@ -108,6 +108,43 @@ int32_t parent_app_main(int, char*[]) { return 0; } +std::atomic g_exit_fixture_result { -1 }; +std::atomic g_exit_fixture_result_received { false }; + +// Starts the exit() fixture as a modal child and stashes the result its exit() status became. +int32_t exit_parent_app_main(int, char*[]) { + TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + + AppEventSubscription sub {}; + app_event_subscribe(&sub, &event_group); + + AppLocation location { APP_LOCATION_PATH, const_cast(EXIT_FIXTURE_APP_PATH) }; + AppInstanceId child_id = 0; + AppStartContext context = app_start_context_for_location(location); + app_start_context_set_parent(&context, app_scheduler_current_app_id()); + app_start_with_context(&context, &child_id); + + bool got_result = false; + while (!got_result) { + if (task_event_group_wait_any(&event_group, nullptr, pdMS_TO_TICKS(5000)) != ERROR_NONE) { + break; // safety net so a bug here can't hang the test suite + } + AppEvent event {}; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_RESULT && event.result.launch_id == child_id) { + g_exit_fixture_result.store(event.result.result, std::memory_order_release); + got_result = true; + } + } + } + g_exit_fixture_result_received.store(got_result, std::memory_order_release); + + app_event_unsubscribe(&sub); + task_event_group_destruct(&event_group); + return 0; +} + std::string g_printf_fixture_output; std::string g_printf_fixture_stderr; @@ -252,3 +289,24 @@ TEST_CASE("app_is_executable() accepts an install-directory-shaped path with bin CHECK(is_executable_path(FIXTURE_INSTALL_DIR_PATH)); } + +TEST_CASE("exit() in a dlopen()ed app with live C++ objects ends only that app, with the exit status as its result") { + ensure_path_loader_registered(); + ensure_memory_loader_registered(); + g_exit_fixture_result.store(-1, std::memory_order_relaxed); + g_exit_fixture_result_received.store(false, std::memory_order_relaxed); + + AppManifest parent_manifest { "test.posix.exit_parent", "ExitParent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(exit_parent_app_main) } }; + REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE); + + AppInstanceId parent_id = 0; + AppStartContext parent_context; + REQUIRE_EQ(app_start_context_from_id("test.posix.exit_parent", &parent_context), ERROR_NONE); + REQUIRE_EQ(app_start_with_context(&parent_context, &parent_id), ERROR_NONE); + REQUIRE(wait_for_state(parent_id, APP_INSTANCE_STATE_STOPPED, 6000)); + + CHECK(g_exit_fixture_result_received.load(std::memory_order_acquire)); + CHECK_EQ(g_exit_fixture_result.load(std::memory_order_acquire), 18); + + app_manager_remove("test.posix.exit_parent"); +} diff --git a/Tactility/Source/app/shell/Run.cpp b/Tactility/Source/app/shell/Run.cpp index 6f05e7a1e..658a5df8d 100644 --- a/Tactility/Source/app/shell/Run.cpp +++ b/Tactility/Source/app/shell/Run.cpp @@ -349,6 +349,8 @@ int runInstalled(const char* id, const char* resolvedPath, int argc, char** argv printf("%s: not found\n", argv[0]); return 127; } + // The manifest's own location string belongs to the install registry, which an uninstall can free before startup + context.location = { APP_LOCATION_PATH, const_cast(resolvedPath) }; return runBinary(context, resolvedPath, argc, argv); } diff --git a/tactility.py b/tactility.py index 1ade7d8e1..45adde95a 100755 --- a/tactility.py +++ b/tactility.py @@ -348,6 +348,8 @@ def sdk_has_sdkconfig(version, platform): return platform.startswith("posix") or os.path.isfile(get_sdk_sdkconfig_path(version, platform)) def copy_sdk_sdkconfig(version, platform): + if platform.startswith("posix"): + return sdkconfig_path = get_sdk_sdkconfig_path(version, platform) if not os.path.isfile(sdkconfig_path): exit_with_error(f"SDK does not contain {sdkconfig_path}") From 7115dd325b6513e8d0dd3e7174e5e6e751067be6 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 27 Sep 2026 17:07:50 +0200 Subject: [PATCH 9/9] Fix for posix --- Modules/app-module/source/scheduler.cpp | 47 ++++++++++++++++++------- Tactility/Source/app/shell/Run.cpp | 2 +- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/Modules/app-module/source/scheduler.cpp b/Modules/app-module/source/scheduler.cpp index f7ed27bbe..7cc064464 100644 --- a/Modules/app-module/source/scheduler.cpp +++ b/Modules/app-module/source/scheduler.cpp @@ -26,12 +26,34 @@ constexpr auto* TAG = "app_scheduler"; -// Slot 0 is reserved by ESP-IDF's pthread API (see TactilityKernel's Thread wrapper for the -// same convention/comment) - app tasks use slot 1 to stash their own app_instance_id, so any -// code running on an app's own task can retrieve it via app_scheduler_current_app_id() without -// needing it threaded through as a parameter. +// The app instance whose task this thread is, so code running on an app's own task can retrieve it via +// app_scheduler_current_app_id() without it being threaded through as a parameter. +#ifdef ESP_PLATFORM +// A FreeRTOS task-local slot rather than thread_local, which faults before the scheduler starts +// (e.g. a write() during early boot). Slot 0 is reserved by ESP-IDF's pthread API. constexpr size_t APP_INSTANCE_ID_THREAD_SLOT_INDEX = 1; +AppInstanceId get_current_app_id() { + return reinterpret_cast(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX)); +} + +void set_current_app_id(AppInstanceId app_instance_id) { + vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, reinterpret_cast(static_cast(app_instance_id))); +} +#else +// thread_local rather than a FreeRTOS task-local slot: on the simulator, that slot is read from whichever +// task is scheduled, even by a foreign thread (e.g. SDL's), which must never be mistaken for an app. +thread_local AppInstanceId current_app_id = 0; + +AppInstanceId get_current_app_id() { + return current_app_id; +} + +void set_current_app_id(AppInstanceId app_instance_id) { + current_app_id = app_instance_id; +} +#endif + // Matches TactilityKernel's Thread wrapper's THREAD_PRIORITY_NORMAL. constexpr UBaseType_t APP_TASK_PRIORITY = 4; @@ -99,9 +121,7 @@ void set_state(AppInstanceId app_instance_id, AppInstanceState state) { mutex_unlock(&ledger.mutex); } -// Set while this thread is an app instance's task running AppLoaderApi::run(). Per thread rather than -// looked up by app_scheduler_current_app_id(): on the simulator, that reads the scheduled FreeRTOS task -// even from a foreign thread, which must never end an app's task. +// Set while this thread is an app instance's task running AppLoaderApi::run(). thread_local TaskContext* current_task_context = nullptr; #ifndef ESP_PLATFORM @@ -243,8 +263,8 @@ void app_task_main(void* context) { current_deferred_unload = &deferred_unload; #endif - check(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX) == nullptr); - vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, reinterpret_cast(static_cast(ctx->app_instance_id))); + check(get_current_app_id() == 0); + set_current_app_id(ctx->app_instance_id); // Debug logging so it's invisible by default // When logging happens, it can distort the application stdout, which breaks apps that use @@ -266,7 +286,7 @@ void finish_app_task(TaskContext* ctx, int32_t result, bool exiting) { // Do a manual flush to ensure data has been written: fflush(stdout); - vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, nullptr); + set_current_app_id(0); #ifndef ESP_PLATFORM if (exiting) { @@ -521,11 +541,14 @@ error_t app_scheduler_stop(AppInstanceId app_instance_id, TickType_t join_timeou } AppInstanceId app_scheduler_current_app_id(void) { - void* value = pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX); - return reinterpret_cast(value); + return get_current_app_id(); } void app_scheduler_exit_current(int32_t status) { + // Checked first: thread_local can't be read before the scheduler starts on ESP32 + if (get_current_app_id() == 0) { + return; + } TaskContext* ctx = current_task_context; if (ctx == nullptr) { return; diff --git a/Tactility/Source/app/shell/Run.cpp b/Tactility/Source/app/shell/Run.cpp index 658a5df8d..e3eb8f6fe 100644 --- a/Tactility/Source/app/shell/Run.cpp +++ b/Tactility/Source/app/shell/Run.cpp @@ -171,7 +171,7 @@ int runApp(AppStartContext& context) { app_io_ioctl(STDOUT_FILENO, APP_IOCTL_GET_WINDOW_SIZE, &windowSize); AppStreamBinding bindings[] = { - { STDIN_FILENO, &stdinStream, stdinBuffer, STDIN_BUFFER_SIZE, &eventGroup, {}, -1 }, + { STDIN_FILENO, &stdinStream, stdinBuffer, STDIN_BUFFER_SIZE, &eventGroup, windowSize, -1 }, { STDOUT_FILENO, &stdoutStream, stdoutBuffer, STDOUT_BUFFER_SIZE, &eventGroup, windowSize, -1 }, { STDERR_FILENO, &stderrStream, stderrBuffer, STDERR_BUFFER_SIZE, &eventGroup, windowSize, -1 }, };