diff --git a/README.md b/README.md index cba6627..15fc3e8 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,14 @@ ESP-IDF normally probes terminal cursor support once while constructing the UART The project therefore preserves safe dumb mode until the first real UART byte arrives. Attach picoterm, picocom, minicom, PuTTY, or another ANSI-capable terminal and press Enter once. That empty line is consumed by the basic reader and promotes the next prompt to enhanced mode, enabling history, Delete, cursor keys, and completion without rebooting. This detects terminal activity rather than electrical USB attachment; a genuinely non-ANSI terminal is not automatically distinguishable on this hardware profile. -The root-level lifecycle command is: +The root-level system commands are: ```text +memory reboot ``` -It acknowledges the request, waits briefly for UART output to drain, and calls the ESP-IDF software restart. RAM-only serial or Wi-Fi changes are lost unless they were persisted first with `serial save` or `wifi save`. +`memory` reports current free space, a conservative aggregate of each matching region's lifetime minimum, and the largest contiguous block for internal 8-bit heap, the internal DMA-capable subset, and external PSRAM. It is intended for comparing idle state with concurrent USB, WebSocket, and SSH sessions. `reboot` acknowledges the request, waits briefly for UART output to drain, and calls the ESP-IDF software restart. RAM-only serial or Wi-Fi changes are lost unless they were persisted first with `serial save` or `wifi save`. ### Phase 1 serial service @@ -136,7 +137,7 @@ broker read [maximum-bytes] broker events ``` -Each connection receives a generation-safe numeric ID. Stale IDs from disconnected clients cannot address a newly reused slot. Up to eight clients may connect, each with a bounded 4096-byte output queue and a 16-entry event queue. +Each connection receives a generation-safe numeric ID. Stale IDs from disconnected clients cannot address a newly reused slot. Up to eight clients may connect, each with a bounded 4096-byte output queue and a 16-entry event queue. The queue control structures remain in internal RAM, while their approximately 35 KiB of payload storage prefers PSRAM with internal fallback; queue semantics and bounds are unchanged. UART RX is copied to every connected client. A full observer queue drops bytes only for that observer and records the loss; it never blocks UART reception or another client. With no clients, the broker still drains UART data and records it as unobserved. @@ -416,7 +417,7 @@ ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no admin@192.16 Compare OpenSSH's first-connection ECDSA fingerprint with `ssh host-key info` before accepting it. Once connected, all terminal bytes go directly to the physical RS-232 session. OpenSSH may print a one-time pre-authentication device banner; this is SSH protocol metadata and is not inserted into the serial stream. -The wolfSSH runtime has one owner task and two fixed session slots. A single owner serializes the managed component's `SINGLE_THREADED` build, avoids one large crypto stack per client, and services both nonblocking sessions fairly. The task has a 20 KiB internal stack; wolfSSL/wolfSSH dynamic allocations prefer external PSRAM and fall back to internal 8-bit memory. WolfCrypt's seed callback is installed after `wolfSSH_Init()` and routes all SSH RNG seeding through the project's serialized pre-radio CTR_DRBG instead of the registry component's default post-radio `esp_random()` path. HTTPS remains on ESP-IDF mbedTLS and is not switched to wolfSSL. +The wolfSSH runtime has one owner task and two fixed session slots. A single owner serializes the managed component's `SINGLE_THREADED` build, avoids one large crypto stack per client, and services both nonblocking sessions fairly. The task is pinned to CPU 1 so SSH key exchange and stream processing cannot monopolize the CPU 0 Wi-Fi/web path. It retains a 20 KiB internal stack because external task stacks are unsafe during flash/NVS cache-disable windows; `ssh status` reports its measured minimum-free stack space so this can be reduced later from hardware evidence rather than guesswork. wolfSSL/wolfSSH dynamic allocations prefer external PSRAM and fall back to internal 8-bit memory. Wi-Fi/lwIP payload allocations and broker observer/event payload queues also prefer PSRAM, preserving internal memory for DMA, task stacks, and control structures. WolfCrypt's seed callback is installed after `wolfSSH_Init()` and routes all SSH RNG seeding through the project's serialized pre-radio CTR_DRBG instead of the registry component's default post-radio `esp_random()` path. HTTPS remains on ESP-IDF mbedTLS and is not switched to wolfSSL. The official Espressif registry baseline is pinned in `src/idf_component.yml` and `dependencies.lock`: @@ -429,12 +430,12 @@ The build enables `CONFIG_ESP_ENABLE_WOLFSSH`, keeps `CONFIG_ESP_TLS_USING_MBEDT #### SSH validation -1. Boot and confirm `ssh status` reports `running=yes`, TCP port 22, password authentication, and no sessions. +1. Boot and record `memory` plus `ssh status`; confirm SSH reports `running=yes`, TCP port 22, password authentication, no sessions, CPU 1 ownership, and a plausible nonzero stack minimum-free value. 2. Compare the first OpenSSH host-key prompt exactly with `ssh host-key info`; disconnect if it differs. 3. Verify a wrong password fails and the shared password from `web credentials show` succeeds. 4. Exercise text, NUL-containing/binary payloads, UTF-8, ANSI color, cursor-control sequences, and a full-screen terminal application through an RS-232 loopback or peer. 5. Confirm `ssh sessions`, `broker clients`, `ssh counters`, and serial counters reflect traffic and cleanup. -6. Connect a second SSH client. It should authenticate as an observer, receive identical UART output, and be unable to inject input while the first owns the writer lease. +6. Connect USB CDC as writer, then one WebSocket observer and one SSH observer. Re-run `memory` and `ssh status`; all transports should remain responsive, internal minimum-free/largest-block values should remain nonzero, and the SSH stack minimum-free value should show whether its 20 KiB allocation can later be reduced safely. 7. Test ownership against native USB and WebSocket clients, using UART0 `broker force-writer` for deterministic reassignment. 8. Run `ssh disconnect ` and verify the socket and broker client disappear without affecting another observer. 9. Leave an unauthenticated TCP/SSH client stalled and verify its slot closes after 15 seconds while an established session continues. diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 839fd4a..e426a35 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -5,6 +5,13 @@ CONFIG_SPIRAM_MODE_OCT=y CONFIG_SPIRAM_SPEED_80M=y CONFIG_SPIRAM_BOOT_INIT=y CONFIG_SPIRAM_USE_CAPS_ALLOC=y +# Preserve internal DMA/task memory by placing Wi-Fi and lwIP payload buffers in PSRAM first. +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +# Retain the previously validated bounded Wi-Fi/lwIP capacities explicitly; +# ESP-IDF changes their defaults when PSRAM-first allocation is enabled. +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10 +CONFIG_ESP_WIFI_RX_BA_WIN=6 +CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4 # Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM. # Active TLS material remains unencrypted in PSRAM until the hardening phase. diff --git a/src/session_broker.c b/src/session_broker.c index 51d1b65..960c8f8 100644 --- a/src/session_broker.c +++ b/src/session_broker.c @@ -2,6 +2,7 @@ #include +#include "esp_heap_caps.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/semphr.h" @@ -16,6 +17,9 @@ #define SESSION_BROKER_SLOT_BITS 3U #define SESSION_BROKER_SLOT_MASK ((1U << SESSION_BROKER_SLOT_BITS) - 1U) #define SESSION_BROKER_MAX_GENERATION (UINT32_MAX >> SESSION_BROKER_SLOT_BITS) +#define SESSION_BROKER_OUTPUT_STORAGE_SIZE (SESSION_BROKER_OUTPUT_SIZE + 1U) +#define SESSION_BROKER_EVENT_STORAGE_SIZE \ + (SESSION_BROKER_EVENT_QUEUE_LENGTH * sizeof(session_broker_event_t)) _Static_assert( SESSION_BROKER_MAX_CLIENTS == (1U << SESSION_BROKER_SLOT_BITS), @@ -24,6 +28,10 @@ _Static_assert( typedef struct { StreamBufferHandle_t output; QueueHandle_t events; + StaticStreamBuffer_t output_control; + StaticQueue_t events_control; + uint8_t *output_storage; + uint8_t *event_storage; session_broker_client_id_t id; uint32_t generation; session_broker_client_type_t type; @@ -183,6 +191,10 @@ static void cleanup_allocations(void) vStreamBufferDelete(s_slots[i].output); s_slots[i].output = NULL; } + heap_caps_free(s_slots[i].event_storage); + s_slots[i].event_storage = NULL; + heap_caps_free(s_slots[i].output_storage); + s_slots[i].output_storage = NULL; } if (s_mutex != NULL) { vSemaphoreDelete(s_mutex); @@ -209,9 +221,25 @@ esp_err_t session_broker_init(void) } for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) { - s_slots[i].output = xStreamBufferCreate(SESSION_BROKER_OUTPUT_SIZE, 1U); - s_slots[i].events = xQueueCreate(SESSION_BROKER_EVENT_QUEUE_LENGTH, - sizeof(session_broker_event_t)); + s_slots[i].output_storage = heap_caps_calloc_prefer( + 1U, SESSION_BROKER_OUTPUT_STORAGE_SIZE, 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + s_slots[i].event_storage = heap_caps_calloc_prefer( + 1U, SESSION_BROKER_EVENT_STORAGE_SIZE, 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (s_slots[i].output_storage != NULL) { + s_slots[i].output = xStreamBufferCreateStatic( + SESSION_BROKER_OUTPUT_STORAGE_SIZE, 1U, + s_slots[i].output_storage, &s_slots[i].output_control); + } + if (s_slots[i].event_storage != NULL) { + s_slots[i].events = xQueueCreateStatic( + SESSION_BROKER_EVENT_QUEUE_LENGTH, + sizeof(session_broker_event_t), + s_slots[i].event_storage, &s_slots[i].events_control); + } if (s_slots[i].output == NULL || s_slots[i].events == NULL) { cleanup_allocations(); return ESP_ERR_NO_MEM; diff --git a/src/ssh_console.c b/src/ssh_console.c index 0d38ded..494bb2f 100644 --- a/src/ssh_console.c +++ b/src/ssh_console.c @@ -94,6 +94,10 @@ static int show_status(bool sessions_only) esp_err_to_name(username_error)); } printf("Admission: shell/PTY only; exec, subsystem, forwarding, SCP, and SFTP disabled\n"); + printf("Owner task: core=%" PRId32 " stack=%" PRIu32 + " minimum-free=%" PRIu32 " bytes\n", + snapshot.task_core_id, snapshot.task_stack_size, + snapshot.task_stack_free_minimum); } return print_sessions(&snapshot); } diff --git a/src/ssh_transport.c b/src/ssh_transport.c index d9ef097..f039aae 100644 --- a/src/ssh_transport.c +++ b/src/ssh_transport.c @@ -28,6 +28,7 @@ #define SSH_TRANSPORT_TASK_STACK_SIZE 20480U #define SSH_TRANSPORT_TASK_PRIORITY 5U +#define SSH_TRANSPORT_TASK_CORE 1 #define SSH_TRANSPORT_LOOP_DELAY_MS 10U #define SSH_TRANSPORT_COMMAND_TIMEOUT_MS 10000U #define SSH_TRANSPORT_RECONCILE_INTERVAL_US 250000LL @@ -984,9 +985,9 @@ esp_err_t ssh_transport_init(void) s_session_snapshots[index].socket_fd = -1; s_session_snapshots[index].state = SSH_TRANSPORT_SESSION_FREE; } - BaseType_t created = xTaskCreate(transport_task, "ssh_transport", - SSH_TRANSPORT_TASK_STACK_SIZE, NULL, - SSH_TRANSPORT_TASK_PRIORITY, &s_task); + BaseType_t created = xTaskCreatePinnedToCore( + transport_task, "ssh_transport", SSH_TRANSPORT_TASK_STACK_SIZE, NULL, + SSH_TRANSPORT_TASK_PRIORITY, &s_task, SSH_TRANSPORT_TASK_CORE); if (created != pdPASS) { s_task = NULL; vSemaphoreDelete(command_mutex); @@ -1127,12 +1128,15 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot) taskEXIT_CRITICAL(&s_lock); return ESP_ERR_INVALID_STATE; } + TaskHandle_t task = s_task; memset(snapshot, 0, sizeof(*snapshot)); snapshot->initialized = s_initialized; snapshot->running = s_running; snapshot->transitioning = s_transitioning; snapshot->port = SSH_TRANSPORT_PORT; snapshot->last_error = s_last_error; + snapshot->task_core_id = SSH_TRANSPORT_TASK_CORE; + snapshot->task_stack_size = SSH_TRANSPORT_TASK_STACK_SIZE; snapshot->counters = s_counters; for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) { snapshot->sessions[index] = s_session_snapshots[index]; @@ -1141,6 +1145,10 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot) } } taskEXIT_CRITICAL(&s_lock); + if (task != NULL) { + snapshot->task_stack_free_minimum = + (uint32_t)uxTaskGetStackHighWaterMark(task); + } return ESP_OK; } diff --git a/src/ssh_transport.h b/src/ssh_transport.h index 49aaf97..63627ce 100644 --- a/src/ssh_transport.h +++ b/src/ssh_transport.h @@ -75,6 +75,9 @@ typedef struct { uint16_t port; esp_err_t last_error; uint32_t active_sessions; + int32_t task_core_id; + uint32_t task_stack_size; + uint32_t task_stack_free_minimum; ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS]; ssh_transport_counters_t counters; } ssh_transport_snapshot_t; diff --git a/src/system_console.c b/src/system_console.c index 8c40399..a416820 100644 --- a/src/system_console.c +++ b/src/system_console.c @@ -3,13 +3,42 @@ #include "system_console.h" +#include #include #include "esp_console.h" +#include "esp_heap_caps.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +static void print_heap_region(const char *name, uint32_t capabilities) +{ + printf("%s: free=%u minimum-free=%u largest-block=%u bytes\n", + name, + (unsigned int)heap_caps_get_free_size(capabilities), + (unsigned int)heap_caps_get_minimum_free_size(capabilities), + (unsigned int)heap_caps_get_largest_free_block(capabilities)); +} + +static int command_memory(int argc, char **argv) +{ + (void)argv; + if (argc != 1) { + printf("Usage: memory\n"); + return 1; + } + + print_heap_region("Internal 8-bit heap", + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + print_heap_region("Internal DMA heap", + MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + print_heap_region("External PSRAM", + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + printf("Minimum-free is a conservative sum of each matching heap region's lifetime minimum.\n"); + return 0; +} + static int command_reboot(int argc, char **argv) { (void)argv; @@ -28,12 +57,24 @@ static int command_reboot(int argc, char **argv) esp_err_t system_console_register_commands(void) { - const esp_console_cmd_t command = { + const esp_console_cmd_t reboot_command = { .command = "reboot", .help = "Restart the ESP32; unsaved RAM-only configuration is lost", .hint = NULL, .func = &command_reboot, .argtable = NULL, }; - return esp_console_cmd_register(&command); + esp_err_t error = esp_console_cmd_register(&reboot_command); + if (error != ESP_OK) { + return error; + } + + const esp_console_cmd_t memory_command = { + .command = "memory", + .help = "Show internal and PSRAM heap availability/low-water marks", + .hint = NULL, + .func = &command_memory, + .argtable = NULL, + }; + return esp_console_cmd_register(&memory_command); }