diff --git a/hosts/esp-idf/README.md b/hosts/esp-idf/README.md new file mode 100644 index 00000000..895a781e --- /dev/null +++ b/hosts/esp-idf/README.md @@ -0,0 +1,105 @@ +# PocketJS on ESP-IDF + +`hosts/esp-idf` is the ESP-IDF product-host half of the network stack: the +QuickJS-ng guest owner, the network modules over the portable core +(`engine/net`) and lwIP, and the board bring-up for the first two profiles. +The renderer side for ESP32-P4 lives in `hosts/esp32p4` (PPA backend). + +| Component | Role | +|---|---| +| `components/pocketjs_net_core` | `engine/net` (HTTP client, HTTP server, WebSocket client cores) plus the BSD-socket driver compiled against lwIP | +| `components/pocketjs_esp_host` | QuickJS-ng guest on one owner task, fixed-rate `frame()` ticks with `begin_tick` before each, `globalThis.net` / `ws` / `httpd` bindings, a network task that services sockets under the runtime lock | +| `components/pocketjs_net_esptls` | ESP-TLS TlsProvider (ESP-TLS + the IDF certificate bundle) for `https:`/`wss:` | +| `components/pocketjs_board` | Wi-Fi station + DHCP + SNTP for the AtomS3R (native Wi-Fi) and the Tab5 (ESP32-P4 rev 1.3 + ESP32-C6 over SDIO via esp_hosted 2.12.12 / esp_wifi_remote 1.6.4, WLAN rail on the PI4IOE5V6408 @0x44 bit 0) | +| `examples/net-smoke` | Headless smoke app (`app.ts`) and the firmware template used by the hardware gate | + +Toolchain: ESP-IDF v6.0.2 (`7101770dc6db`), QuickJS-ng 0.14.0 from the +component registry, Bun for the guest bundle. + +## Execution model + +The guest runs only inside `frame()` on the owner task. Before every +frame the owner +task calls `pnet_runtime_begin_tick()`, which freezes the visible event set; +inside `frame()` the framework service pump calls each module's `poll` once +and copies bodies out with `readInto`; Promise reactions run in the job +drain right after `frame()`. The network task never touches QuickJS: it +runs `pnet_runtime_service()` under the same mutex the bindings take, waits +in `select()` with the core's next deadline, and is woken through a +loopback UDP socket whenever the guest issued an op. DNS lookups run on the +driver's own `pnet-dns` task, never on the network task. + +Tick k is scheduled at `t0 + k / tick_hz` on the microsecond timer, so a +60 Hz guest runs at **60.00 Hz** (an integer 16 ms FreeRTOS period would be +62.5 Hz and drift the virtual clock from the wall clock by 4 %). A frame that +overruns makes the next ticks late, and each late tick still gets its one +turn (Law 3); only a host more than 0.5 s behind drops ticks, counted in +`stats.frames_skipped`. Shutdown is a single unwind: `stop()` asks both +tasks to exit, bounds a guest turn in progress through the QuickJS interrupt +handler, waits for both exit flags and only then frees; a failed start +releases everything it created. + +## Build Plan inputs + +The firmware authors no network policy. `examples/net-smoke/pocket.json` is +a **format 3** manifest (`permissions.network`); `tools/esp-idf.ts`, run by +`main/CMakeLists.txt`, merges the rig's endpoints (Kconfig: workstation +peer, peer board, serve port, TLS host), resolves the plan against the +board's private profile (`tools/esp-idf-profile.ts`: `atoms3r-dev` / +`tab5-dev`, advertising the HTTP client (+TLS), HTTP server and WebSocket +client (+TLS) roles) and writes into the build directory: + +| File | Use | +|---|---| +| `network-policy.json` | the canonical `ResolvedNetworkPolicy` (plan truth, covered by `planHash`), embedded and passed to `pnet_runtime_create` verbatim | +| `host-inputs.h` | `POCKETJS_PLAN_HASH`, target, resolved features (`POCKETJS_FEATURE_*`) — the roles `main.c` mounts | +| `app.js` | the guest bundle built against the same plan | +| `plan.json`, `pocket.resolved.json` | the plan and the merged manifest, for inspection | + +`wall_clock_trusted` is a board state, not a date check: the board layer +latches it when an SNTP sync completes (`pocketjs_board_sync_time`, and every +re-sync through the SNTP notification) or when the product asserts it; until +then every verifying TLS connection fails closed with `tls_clock_untrusted`. + +## Hardware smoke (plaintext) + +`examples/net-smoke` against `bun tools/net-peer.ts` on the workstation and +board-to-board, both boards serving on :8080: + +| Board | Result | +|---|---| +| AtomS3R (ESP32-S3-PICO-1-N8R8) | 20/20 plaintext + 6 TLS = 26/26: GET/POST/JSON/chunked/404, redirect follow+manual, 200 KB body through an 8 KiB queue at ~350 KiB/s, aggregate limit, headers timeout, permission_denied, connect refused, WebSocket echo (text/binary/ping/pong/close), peer board GET/POST/JSON/stream/404, continuous pings | +| Tab5 (ESP32-P4 rev 1.3 + C6) | 26/26, same suite, ~370 KiB/s | + +The TLS block (enable `CONFIG_SMOKE_ENABLE_TLS=y`) needs internet and +an SNTP sync: HTTPS/1.1 to a public host with a valid chain from the IDF +certificate bundle, plus badssl.com's expired / wrong-host / self-signed / +untrusted-root endpoints, all failing closed. Hostname mismatch reports +`tls_hostname_mismatch`; the other certificate faults report +`tls_certificate_invalid` or `tls_handshake_failed` (ESP-TLS exposes the +Mbed TLS verify flags inconsistently on the async path) — the precise +per-fault codes are proven in the desktop OpenSSL conformance suite. + +Steady state after 60 s: guest heap ≈363 KB (high water ≈686 KB during +bundle evaluation), core heap ≈4 KB, one socket per live connection, no +growth. An earlier 12-minute board-to-board soak (43,200 frames, 330 HTTP +round trips each way, both boards serving the other) ended with zero +failures and the same heap figures — it ran on the 16 ms (62.5 Hz) host, so +its "12 minutes" was the frame count ÷ 60 and about 11.5 min of wall clock; +the exact-cadence host reports 1800 frames per 30.0 s of uptime in its +periodic stats. Bundle evaluation of the 116 KB smoke IIFE: ≈780 ms on the +S3, ≈350 ms on the P4. ESP-TLS handshake steps run under the runtime lock, so +a handshake stalls the guest's `begin_tick` for up to a couple of seconds; +the overload guard shows this as `frames_skipped` during the TLS block. + +## Tab5 pitfalls + +- Rev 1.3 silicon needs `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y` and + `CONFIG_ESP32P4_REV_MIN_100=y`; the default v3-only image does not boot. +- The C6 sits behind the SDIO1 preset (`CONFIG_ESP32P4_TAB5_C6_BOARD=y`: + CLK 12, CMD 13, D0–D3 11/10/9/8, reset GPIO 15) and needs + `CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y` — GPIO15 drives EN through + 1 kΩ; the active-low default leaves the C6 held in reset (SDIO CMD5 + timeout). +- Power the WLAN rail before `esp_wifi_init()` (`pocketjs_board_prepare_wifi`). +- `CONFIG_FREERTOS_HZ=1000` keeps the hosted transport free of bus jitter warnings. diff --git a/hosts/esp-idf/components/pocketjs_board/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_board/CMakeLists.txt new file mode 100644 index 00000000..351c025a --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/CMakeLists.txt @@ -0,0 +1,10 @@ +set(reqs esp_wifi esp_netif esp_event nvs_flash esp_driver_i2c) +if(CONFIG_IDF_TARGET_ESP32P4) + list(APPEND reqs esp_wifi_remote esp_hosted) +endif() +idf_component_register( + SRCS "src/board_wifi.c" "src/board_prepare.c" + INCLUDE_DIRS "include" + REQUIRES ${reqs} + PRIV_REQUIRES log freertos) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_board/idf_component.yml b/hosts/esp-idf/components/pocketjs_board/idf_component.yml new file mode 100644 index 00000000..72128fae --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/idf_component.yml @@ -0,0 +1,13 @@ +description: Wi-Fi station bring-up for the AtomS3R (ESP32-S3) and Tab5 (ESP32-P4 + C6) PocketJS profiles. +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" + espressif/esp_wifi_remote: + version: "1.6.4" + rules: + - if: "target in [esp32p4]" + espressif/esp_hosted: + version: "2.12.12" + rules: + - if: "target in [esp32p4]" diff --git a/hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h b/hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h new file mode 100644 index 00000000..142f5bba --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h @@ -0,0 +1,65 @@ +/* Board bring-up for the first two ESP-IDF PocketJS profiles: + * + * AtomS3R ESP32-S3-PICO-1-N8R8, native Wi-Fi. + * Tab5 ESP32-P4 rev 1.3 + on-board ESP32-C6 over SDIO (esp_hosted + + * esp_wifi_remote); the C6 power rail sits behind the PI4IOE5V6408 + * IO expander at 0x44 (bit 0, WLAN_PWR_EN) on the internal I2C bus + * (SDA GPIO31, SCL GPIO32) and must be on before esp_wifi_init(). + * + * The public network modules never see any of this: link driver, BSP and + * credentials are product/host concerns. This component gives the smoke + * firmware one call that brings the + * station interface up with DHCP and returns the address. + */ +#ifndef POCKETJS_BOARD_H +#define POCKETJS_BOARD_H + +#include +#include +#include + +#include "esp_err.h" +#include "esp_netif_ip_addr.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pocketjs_board_wifi_config { + const char *ssid; + const char *password; + /** Wait for DHCP this long (0 = 30 s). */ + uint32_t timeout_ms; +} pocketjs_board_wifi_config; + +/** Board-specific power/transport preparation (Tab5: enable the C6 rail and + * start the hosted transport). No-op on AtomS3R. Idempotent. */ +esp_err_t pocketjs_board_prepare_wifi(void); + +/** NVS + netif + event loop + STA + DHCP; returns once an IPv4 address is + * bound (written to *ip) or fails after the timeout. Reconnects on drops. */ +esp_err_t pocketjs_board_wifi_connect(const pocketjs_board_wifi_config *cfg, esp_ip4_addr_t *ip); + +/** Current station IPv4 address as text ("0.0.0.0" when down). */ +void pocketjs_board_ip_text(char *out, size_t cap); + +/** Sync the wall clock over SNTP. Returns ESP_OK once the time is set (the + * clock is then trusted, see below), ESP_ERR_TIMEOUT otherwise. */ +esp_err_t pocketjs_board_sync_time(uint32_t timeout_ms); + +/** Wall-clock trust state for TLS certificate validation — a state the board + * layer maintains, not a guess from the date: true after an SNTP sync + * completed (pocketjs_board_sync_time, or any later SNTP re-sync reported + * through the sync notification), or after the product asserted it with + * pocketjs_board_set_clock_trusted (a validated battery-backed RTC, + * provisioning). Wire it into pocketjs_esp_host_config.wall_clock_trusted. */ +bool pocketjs_board_clock_trusted(void); +void pocketjs_board_set_clock_trusted(bool trusted); +/** Adapter with the host's callback signature (ignores `user`). */ +bool pocketjs_board_clock_trusted_cb(void *user); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_BOARD_H */ diff --git a/hosts/esp-idf/components/pocketjs_board/src/board_prepare.c b/hosts/esp-idf/components/pocketjs_board/src/board_prepare.c new file mode 100644 index 00000000..1e09d7ef --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/src/board_prepare.c @@ -0,0 +1,112 @@ +/* Board-specific preparation before esp_wifi_init(). */ +#include "pocketjs/board.h" + +#include "esp_log.h" +#include "sdkconfig.h" + +static const char *TAG = "board"; + +#if CONFIG_IDF_TARGET_ESP32P4 +/* Tab5: the ESP32-C6 module is powered through the second PI4IOE5V6408 IO + * expander (0x44, bit 0 = WLAN_PWR_EN) on the internal I2C bus, and reached + * over SDIO through esp_hosted. The expander register values are the ones + * M5Stack's Tab5 demo programs; only bit 0 matters here. GPIO15 (P4) drives + * the C6 EN pin through 1 kΩ and is left to esp_hosted's reset sequence, + * which the sdkconfig must configure active-high. */ +#include "driver/i2c_master.h" +#include "esp_hosted.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define TAB5_I2C_PORT 0 +#define TAB5_I2C_SDA 31 +#define TAB5_I2C_SCL 32 +#define TAB5_PI4IOE2_ADDR 0x44 +#define PI4IO_REG_CHIP_RESET 0x01 +#define PI4IO_REG_IO_DIR 0x03 +#define PI4IO_REG_OUT_SET 0x05 +#define PI4IO_REG_OUT_H_IM 0x07 +#define PI4IO_REG_PULL_EN 0x0B +#define PI4IO_REG_PULL_SEL 0x0D + +static bool s_prepared; + +static esp_err_t pi4io_write(i2c_master_dev_handle_t dev, uint8_t reg, uint8_t value) { + uint8_t buf[2] = {reg, value}; + return i2c_master_transmit(dev, buf, sizeof buf, 100); +} + +static esp_err_t tab5_power_wlan(void) { + i2c_master_bus_config_t bus_cfg = { + .clk_source = I2C_CLK_SRC_DEFAULT, + .i2c_port = TAB5_I2C_PORT, + .sda_io_num = TAB5_I2C_SDA, + .scl_io_num = TAB5_I2C_SCL, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + i2c_master_bus_handle_t bus; + esp_err_t err = i2c_new_master_bus(&bus_cfg, &bus); + if (err != ESP_OK) { + /* The bus may already exist (a display BSP created it). */ + err = i2c_master_get_bus_handle(TAB5_I2C_PORT, &bus); + if (err != ESP_OK) return err; + } + i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = TAB5_PI4IOE2_ADDR, + .scl_speed_hz = 400000, + }; + i2c_master_dev_handle_t dev; + err = i2c_master_bus_add_device(bus, &dev_cfg, &dev); + if (err != ESP_OK) return err; + /* Same programming as the M5Stack Tab5 demo for PI4IOE2. */ + err = pi4io_write(dev, PI4IO_REG_IO_DIR, 0xB9); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_OUT_SET, 0x09); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_OUT_H_IM, 0x06); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_PULL_EN, 0xF9); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_PULL_SEL, 0xB9); + if (err == ESP_OK) { + /* WLAN_PWR_EN = bit 0 high (read-modify-write like bsp_set_wifi_power_enable). */ + uint8_t reg = PI4IO_REG_OUT_SET; + uint8_t cur = 0; + if (i2c_master_transmit_receive(dev, ®, 1, &cur, 1, 100) == ESP_OK) { + err = pi4io_write(dev, PI4IO_REG_OUT_SET, (uint8_t)(cur | 0x01)); + } else { + err = pi4io_write(dev, PI4IO_REG_OUT_SET, 0x09); + } + } + i2c_master_bus_rm_device(dev); + if (err != ESP_OK) return err; + vTaskDelay(pdMS_TO_TICKS(200)); /* rail settle before the C6 reset sequence */ + return ESP_OK; +} + +esp_err_t pocketjs_board_prepare_wifi(void) { + if (s_prepared) return ESP_OK; + ESP_LOGI(TAG, "Tab5: enabling the WLAN power rail"); + esp_err_t err = tab5_power_wlan(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Tab5: WLAN_PWR_EN failed: %s", esp_err_to_name(err)); + return err; + } + ESP_LOGI(TAG, "Tab5: starting the esp_hosted SDIO transport to the C6"); + int rc = esp_hosted_init(); + if (rc != 0) { + ESP_LOGE(TAG, "esp_hosted_init: %d", rc); + return ESP_FAIL; + } + rc = esp_hosted_connect_to_slave(); + if (rc != 0) { + ESP_LOGE(TAG, "esp_hosted_connect_to_slave: %d", rc); + return ESP_FAIL; + } + s_prepared = true; + return ESP_OK; +} +#else +esp_err_t pocketjs_board_prepare_wifi(void) { + ESP_LOGI(TAG, "native Wi-Fi: no board preparation needed"); + return ESP_OK; +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_board/src/board_wifi.c b/hosts/esp-idf/components/pocketjs_board/src/board_wifi.c new file mode 100644 index 00000000..1eb03547 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/src/board_wifi.c @@ -0,0 +1,133 @@ +/* Wi-Fi station bring-up shared by the AtomS3R and Tab5 profiles. */ +#include "pocketjs/board.h" + +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" +#include "esp_netif_sntp.h" +#include "esp_sntp.h" +#include "nvs_flash.h" +#include "sdkconfig.h" +#include + +static const char *TAG = "board"; + +static EventGroupHandle_t s_events; +static esp_ip4_addr_t s_ip; +static int s_retries; +static bool s_started; +#define GOT_IP_BIT BIT0 +#define FAILED_BIT BIT1 + +static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) { + (void)arg; + (void)data; + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + memset(&s_ip, 0, sizeof s_ip); + s_retries++; + ESP_LOGW(TAG, "station disconnected (attempt %d), reconnecting", s_retries); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_wifi_connect(); + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *ev = data; + s_ip = ev->ip_info.ip; + ESP_LOGI(TAG, "station got ip " IPSTR, IP2STR(&s_ip)); + xEventGroupSetBits(s_events, GOT_IP_BIT); + } +} + +esp_err_t pocketjs_board_wifi_connect(const pocketjs_board_wifi_config *cfg, esp_ip4_addr_t *ip) { + if (!cfg || !cfg->ssid) return ESP_ERR_INVALID_ARG; + if (!s_started) { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + ESP_ERROR_CHECK(pocketjs_board_prepare_wifi()); + esp_netif_create_default_wifi_sta(); + wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&init)); + s_events = xEventGroupCreate(); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, on_wifi, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi, NULL)); + wifi_config_t wc; + memset(&wc, 0, sizeof wc); + strncpy((char *)wc.sta.ssid, cfg->ssid, sizeof wc.sta.ssid - 1); + if (cfg->password) strncpy((char *)wc.sta.password, cfg->password, sizeof wc.sta.password - 1); + wc.sta.threshold.authmode = cfg->password && cfg->password[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; + wc.sta.pmf_cfg.capable = true; + wc.sta.pmf_cfg.required = false; + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc)); + ESP_ERROR_CHECK(esp_wifi_start()); + s_started = true; + ESP_LOGI(TAG, "connecting to \"%s\"", cfg->ssid); + } + uint32_t timeout = cfg->timeout_ms ? cfg->timeout_ms : 30000; + EventBits_t bits = xEventGroupWaitBits(s_events, GOT_IP_BIT, pdFALSE, pdFALSE, pdMS_TO_TICKS(timeout)); + if (!(bits & GOT_IP_BIT)) { + ESP_LOGE(TAG, "no address after %u ms", (unsigned)timeout); + return ESP_ERR_TIMEOUT; + } + if (ip) *ip = s_ip; + return ESP_OK; +} + +/* Wall-clock trust: latched by a completed SNTP sync (first sync and every + * later re-sync, through the notification callback) or by the product. */ +static volatile bool s_clock_trusted; + +static void on_time_synced(struct timeval *tv) { + (void)tv; + s_clock_trusted = true; +} + +bool pocketjs_board_clock_trusted(void) { + return s_clock_trusted; +} + +void pocketjs_board_set_clock_trusted(bool trusted) { + s_clock_trusted = trusted; +} + +bool pocketjs_board_clock_trusted_cb(void *user) { + (void)user; + return s_clock_trusted; +} + +esp_err_t pocketjs_board_sync_time(uint32_t timeout_ms) { + static bool started; + if (!started) { + esp_sntp_config_t cfg = ESP_NETIF_SNTP_DEFAULT_CONFIG("pool.ntp.org"); + cfg.sync_cb = on_time_synced; + ESP_ERROR_CHECK(esp_netif_sntp_init(&cfg)); + started = true; + } + if (esp_netif_sntp_sync_wait(pdMS_TO_TICKS(timeout_ms ? timeout_ms : 15000)) != ESP_OK) { + ESP_LOGW(TAG, "SNTP did not sync in time; the wall clock stays untrusted (TLS fails closed)"); + return ESP_ERR_TIMEOUT; + } + s_clock_trusted = true; + time_t now = time(NULL); + struct tm tm; + localtime_r(&now, &tm); + ESP_LOGI(TAG, "time synced: %04d-%02d-%02d %02d:%02d:%02d UTC (wall clock trusted)", tm.tm_year + 1900, tm.tm_mon + 1, + tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec); + return ESP_OK; +} + +void pocketjs_board_ip_text(char *out, size_t cap) { + snprintf(out, cap, IPSTR, IP2STR(&s_ip)); +} diff --git a/hosts/esp-idf/components/pocketjs_esp_host/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_esp_host/CMakeLists.txt new file mode 100644 index 00000000..8d38fbae --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "src/host.c" "src/net_binding.c" + INCLUDE_DIRS "include" + REQUIRES pocketjs_net_core pocketjs_net_esptls quickjs-ng + PRIV_REQUIRES esp_timer heap freertos log) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_esp_host/idf_component.yml b/hosts/esp-idf/components/pocketjs_esp_host/idf_component.yml new file mode 100644 index 00000000..0ccf4356 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/idf_component.yml @@ -0,0 +1,7 @@ +description: PocketJS QuickJS-ng guest owner with the network modules for ESP-IDF. +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" + espressif/quickjs-ng: + version: "0.14.0" diff --git a/hosts/esp-idf/components/pocketjs_esp_host/include/pocketjs/esp_host.h b/hosts/esp-idf/components/pocketjs_esp_host/include/pocketjs/esp_host.h new file mode 100644 index 00000000..f16eb2db --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/include/pocketjs/esp_host.h @@ -0,0 +1,141 @@ +/* PocketJS ESP-IDF host: a QuickJS-ng guest owned by one FreeRTOS task, + * ticked at a fixed rate through `globalThis.frame(...)`, with the network + * modules (`globalThis.net` / `ws` / `httpd`) mounted over the portable core + * (engine/net) and a network task driving lwIP sockets. + * + * Execution model: every guest turn is one `frame()` call followed by the + * job drain, on the owner task only. Before each frame the owner task runs + * `pnet_runtime_begin_tick()` under the runtime lock; the network task + * services sockets under the same lock and never touches QuickJS. + * + * Cadence: tick k is scheduled at t0 + k / tick_hz (absolute microsecond + * deadlines), so the host's real tick rate equals the realm's `__simHz` + * exactly (60 Hz is 60 Hz, not the 62.5 Hz a 16 ms integer period gives), + * and every tick gets its one guest turn (Law 3): after a frame overruns, + * the late ticks run back to back until the schedule is caught up. Only a + * host that falls more than half a second behind drops the excess ticks and + * resyncs (stats.frames_skipped) — an overload guard, not the normal path. + * + * Ownership: the host owns the guest task, the network task, the runtime, + * the driver and the TLS provider. Startup unwinds everything it created on + * any failure; stop() releases a resource only after the task that uses it + * has definitely exited. A guest turn in progress while stopping is bounded + * by the QuickJS interrupt handler (stop_turn_budget_ms), so stop() never + * frees under a running turn. + * + * Build Plan truth: the network policy is the application's + * ResolvedNetworkPolicy (contracts/spec/network-policy.ts), handed over as + * the canonical JSON the plan resolver emits (HostBuildInputs.network. + * policyJson); a product host embeds that projection, it never writes a + * policy of its own. Which modules to mount follows the plan's features. + */ +#ifndef POCKETJS_ESP_HOST_H +#define POCKETJS_ESP_HOST_H + +#include +#include +#include + +#include "esp_err.h" +#include "pocketjs/net/runtime.h" +#include "quickjs.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pocketjs_esp_host pocketjs_esp_host_t; + +typedef struct pocketjs_esp_host_config { + /** Guest ticks per second (the realm's `__simHz`). Default 60. */ + uint32_t tick_hz; + /** QuickJS memory limit in bytes (0 = 4 MiB). */ + size_t guest_memory_limit; + /** QuickJS stack limit in bytes (0 = 3/4 of the guest task stack). */ + size_t guest_stack_limit; + /** Allocate the QuickJS heap from PSRAM (recommended when present). */ + bool guest_in_psram; + /** Owner task stack bytes (default 32 KiB) and priority/core. */ + uint32_t guest_task_stack; + int guest_task_priority; + int guest_task_core; + /** Network task stack bytes (default 12 KiB) and priority/core. */ + uint32_t net_task_stack; + int net_task_priority; + int net_task_core; + /** While stopping, a guest turn (frame + job drain) longer than this is + * interrupted (QuickJS interrupt handler) so shutdown is bounded. + * Default 50 ms; 0 = default. */ + uint32_t stop_turn_budget_ms; + /** The application's network policy: the canonical ResolvedNetworkPolicy + * JSON from its Build Plan (version 1). NULL mounts no network module. + * Never a host-authored string — see the header comment. */ + const char *network_policy_json; + /** The plan's checksum (ResolvedBuildPlan.planHash), logged at boot and + * reported in stats so a running device names the plan it runs. Optional. */ + const char *plan_hash; + /** Enable TLS (https:/wss:) through the ESP-TLS provider with the IDF + * certificate bundle. */ + bool network_tls; + /** Whether the wall clock is trusted for certificate validity: true only + * after the platform established it (SNTP sync completed, a validated + * persisted RTC, explicit provisioning) — "the clock has a plausible + * value" is not trust. Required for TLS: while it returns false (or when + * NULL) every verifying connection fails closed with tls_clock_untrusted + * before any I/O. The board layer provides it (pocketjs_board_clock_trusted). */ + bool (*wall_clock_trusted)(void *user); + /** Which roles this host admits: `globalThis.net` is always mounted with + * a policy; `ws` and `httpd` only when set (default true for both). A + * product host mounts exactly the roles its plan's features turned on. */ + bool mount_websocket_client; + bool mount_http_server; + /** Core limits; NULL = spec ceilings tightened by the host defaults. */ + const pnet_runtime_config *network_config; + /** Sockets the driver may track (default 12). */ + int network_max_sockets; + /** Called on the owner task after the namespaces are mounted and before + * the bundle is evaluated (install host globals). */ + void (*before_eval)(JSContext *ctx, void *user); + /** Called on the owner task after every frame + job drain (diagnostics). */ + void (*after_frame)(uint32_t frame, void *user); + void *user; +} pocketjs_esp_host_config; + +/** Fill in the defaults described above. */ +void pocketjs_esp_host_config_defaults(pocketjs_esp_host_config *cfg); + +/** Create the runtime and both tasks, evaluate `bundle` (an IIFE that + * installs `globalThis.frame`), and start ticking. `bundle` must stay valid + * for the host's lifetime (embedded flash text is fine). On failure nothing + * is left allocated and *out_host is untouched. */ +esp_err_t pocketjs_esp_host_start(const pocketjs_esp_host_config *cfg, const char *bundle, size_t bundle_len, + pocketjs_esp_host_t **out_host); + +/** Quiesce the network, run a bounded number of wind-down frames, wait for + * both tasks to exit, then release the guest, the runtime, the driver and + * the TLS provider. Blocks the caller until the tasks are gone; if a task + * does not exit within the (generous) deadline the host is leaked with an + * error log rather than freed under a running task. */ +void pocketjs_esp_host_stop(pocketjs_esp_host_t *host); + +typedef struct pocketjs_esp_host_stats { + uint32_t frames; /* guest turns run */ + uint32_t frames_skipped; /* ticks dropped by the overload guard (> 0.5 s behind) */ + uint32_t jobs; + uint32_t frame_errors; + size_t guest_heap_bytes; /* QuickJS reported */ + size_t guest_heap_high_water; + size_t net_heap_bytes; /* core accounting */ + int net_sockets; + uint32_t frame_max_us; + bool guest_boot_failed; /* the bundle did not evaluate; the host idles */ + const char *plan_hash; /* cfg.plan_hash or "" */ +} pocketjs_esp_host_stats_t; + +void pocketjs_esp_host_stats(pocketjs_esp_host_t *host, pocketjs_esp_host_stats_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_ESP_HOST_H */ diff --git a/hosts/esp-idf/components/pocketjs_esp_host/src/host.c b/hosts/esp-idf/components/pocketjs_esp_host/src/host.c new file mode 100644 index 00000000..277a1f43 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/src/host.c @@ -0,0 +1,588 @@ +/* PocketJS ESP-IDF host: guest owner task, network task, lifecycle. */ +#include "host_internal.h" + +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_random.h" +#include "esp_timer.h" + +static const char *TAG = "pocketjs"; + +/* ------------------------------------------------------------------------ */ +/* Config */ +/* ------------------------------------------------------------------------ */ + +void pocketjs_esp_host_config_defaults(pocketjs_esp_host_config *cfg) { + memset(cfg, 0, sizeof *cfg); + cfg->tick_hz = 60; + cfg->guest_memory_limit = 4 * 1024 * 1024; + cfg->guest_stack_limit = 0; + cfg->guest_in_psram = true; + cfg->guest_task_stack = 32 * 1024; + cfg->guest_task_priority = 5; + cfg->guest_task_core = tskNO_AFFINITY; + cfg->net_task_stack = 12 * 1024; + cfg->net_task_priority = 8; + cfg->net_task_core = tskNO_AFFINITY; + cfg->stop_turn_budget_ms = 50; + cfg->network_policy_json = NULL; + cfg->plan_hash = NULL; + cfg->network_tls = false; + cfg->wall_clock_trusted = NULL; + cfg->mount_websocket_client = true; + cfg->mount_http_server = true; + cfg->network_config = NULL; + cfg->network_max_sockets = 12; +} + +/* ------------------------------------------------------------------------ */ +/* QuickJS allocator: PSRAM when requested, byte accounting */ +/* ------------------------------------------------------------------------ */ + +static uint32_t heap_caps_for(pocketjs_esp_host_t *host) { + return host->cfg.guest_in_psram ? (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) : MALLOC_CAP_8BIT; +} + +static void account(pocketjs_esp_host_t *host, void *ptr, bool add) { + if (!ptr) return; + size_t size = heap_caps_get_allocated_size(ptr); + if (add) { + host->guest_heap += size; + if (host->guest_heap > host->guest_heap_high_water) host->guest_heap_high_water = host->guest_heap; + } else { + host->guest_heap = host->guest_heap >= size ? host->guest_heap - size : 0; + } +} + +static void *guest_calloc(void *opaque, size_t count, size_t size) { + pocketjs_esp_host_t *host = opaque; + void *p = heap_caps_calloc(count, size, heap_caps_for(host)); + account(host, p, true); + return p; +} + +static void *guest_malloc(void *opaque, size_t size) { + pocketjs_esp_host_t *host = opaque; + void *p = heap_caps_malloc(size, heap_caps_for(host)); + account(host, p, true); + return p; +} + +static void guest_free(void *opaque, void *ptr) { + pocketjs_esp_host_t *host = opaque; + account(host, ptr, false); + heap_caps_free(ptr); +} + +static void *guest_realloc(void *opaque, void *ptr, size_t size) { + pocketjs_esp_host_t *host = opaque; + if (size == 0) { + guest_free(opaque, ptr); + return NULL; + } + account(host, ptr, false); + void *p = heap_caps_realloc(ptr, size, heap_caps_for(host)); + if (!p) { + account(host, ptr, true); + return NULL; + } + account(host, p, true); + return p; +} + +static size_t guest_usable_size(const void *ptr) { + return ptr ? heap_caps_get_allocated_size((void *)ptr) : 0; +} + +static const JSMallocFunctions GUEST_ALLOC = { + .js_calloc = guest_calloc, + .js_malloc = guest_malloc, + .js_free = guest_free, + .js_realloc = guest_realloc, + .js_malloc_usable_size = guest_usable_size, +}; + +/* ------------------------------------------------------------------------ */ +/* Network core platform */ +/* ------------------------------------------------------------------------ */ + +static uint64_t plat_now_ms(void *ctx) { + (void)ctx; + return (uint64_t)(esp_timer_get_time() / 1000); +} + +static void *plat_alloc(void *ctx, size_t size) { + (void)ctx; + /* Prefer PSRAM for payload buffers; fall back to internal RAM. */ + void *p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT); + return p; +} + +static void plat_free(void *ctx, void *ptr, size_t size) { + (void)ctx; + (void)size; + heap_caps_free(ptr); +} + +static void plat_random(void *ctx, uint8_t *out, size_t len) { + (void)ctx; + esp_fill_random(out, len); +} + +static bool plat_clock_trusted(void *ctx) { + /* Trust is a platform state the board/product layer maintains (SNTP sync + * completed, validated RTC, provisioning) — never "the date looks + * plausible". No callback = never trusted = TLS fails closed. */ + pocketjs_esp_host_t *host = ctx; + return host->cfg.wall_clock_trusted ? host->cfg.wall_clock_trusted(host->cfg.user) : false; +} + +static void plat_log(void *ctx, pnet_log_level level, const char *msg) { + (void)ctx; + switch (level) { + case PNET_LOG_ERROR: ESP_LOGE("pnet", "%s", msg); break; + case PNET_LOG_WARN: ESP_LOGW("pnet", "%s", msg); break; + case PNET_LOG_INFO: ESP_LOGI("pnet", "%s", msg); break; + default: ESP_LOGD("pnet", "%s", msg); break; + } +} + +/* ------------------------------------------------------------------------ */ +/* Guest console */ +/* ------------------------------------------------------------------------ */ + +static JSValue console_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic) { + (void)this_val; + char line[512]; + size_t used = 0; + for (int i = 0; i < argc && used + 2 < sizeof line; i++) { + const char *s = JS_ToCString(ctx, argv[i]); + if (!s) continue; + int n = snprintf(line + used, sizeof line - used, "%s%s", i ? " " : "", s); + JS_FreeCString(ctx, s); + if (n > 0) used += (size_t)n < sizeof line - used ? (size_t)n : sizeof line - used - 1; + } + line[used] = 0; + switch (magic) { + case 0: ESP_LOGE("guest", "%s", line); break; + case 1: ESP_LOGW("guest", "%s", line); break; + default: ESP_LOGI("guest", "%s", line); break; + } + return JS_UNDEFINED; +} + +static void install_console(JSContext *ctx) { + JSValue global = JS_GetGlobalObject(ctx); + JSValue console = JS_NewObject(ctx); + JS_SetPropertyStr(ctx, console, "error", JS_NewCFunctionMagic(ctx, console_write, "error", 1, JS_CFUNC_generic_magic, 0)); + JS_SetPropertyStr(ctx, console, "warn", JS_NewCFunctionMagic(ctx, console_write, "warn", 1, JS_CFUNC_generic_magic, 1)); + JS_SetPropertyStr(ctx, console, "log", JS_NewCFunctionMagic(ctx, console_write, "log", 1, JS_CFUNC_generic_magic, 2)); + JS_SetPropertyStr(ctx, console, "info", JS_NewCFunctionMagic(ctx, console_write, "info", 1, JS_CFUNC_generic_magic, 2)); + JS_SetPropertyStr(ctx, console, "debug", JS_NewCFunctionMagic(ctx, console_write, "debug", 1, JS_CFUNC_generic_magic, 3)); + JS_SetPropertyStr(ctx, global, "console", console); + JS_FreeValue(ctx, global); +} + +static void log_exception(JSContext *ctx, const char *phase) { + JSValue exc = JS_GetException(ctx); + const char *msg = JS_ToCString(ctx, exc); + ESP_LOGE("guest", "%s: %s", phase, msg ? msg : "(exception)"); + if (msg) JS_FreeCString(ctx, msg); + if (JS_IsObject(exc)) { + JSValue stack = JS_GetPropertyStr(ctx, exc, "stack"); + const char *st = JS_ToCString(ctx, stack); + if (st && *st) ESP_LOGE("guest", "%s", st); + if (st) JS_FreeCString(ctx, st); + JS_FreeValue(ctx, stack); + } + JS_FreeValue(ctx, exc); +} + +/* ------------------------------------------------------------------------ */ +/* Task exit protocol */ +/* ------------------------------------------------------------------------ */ + +/* The last thing a task does with `host`: publish its done flag, then wake + * whoever waits in stop()/unwind. The waiter handle is read BEFORE the flag + * is published (after the flag the owner may free `host`). */ +static void task_exit(pocketjs_esp_host_t *host, volatile bool *done_flag) { + TaskHandle_t waiter = __atomic_load_n(&host->stop_waiter, __ATOMIC_SEQ_CST); + __atomic_store_n(done_flag, true, __ATOMIC_SEQ_CST); + if (waiter) xTaskNotifyGive(waiter); + vTaskDelete(NULL); +} + +/* Block until `flag` is set: notification-driven with a short poll fallback + * (a task that finished before the waiter registered itself never notifies). + * Returns false if the deadline passed. */ +static bool wait_flag(volatile bool *flag, uint32_t deadline_ms) { + int64_t end = esp_timer_get_time() + (int64_t)deadline_ms * 1000; + while (!__atomic_load_n(flag, __ATOMIC_SEQ_CST)) { + if (esp_timer_get_time() >= end) return false; + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + return true; +} + +/* ------------------------------------------------------------------------ */ +/* Network task */ +/* ------------------------------------------------------------------------ */ + +static void net_task(void *arg) { + pocketjs_esp_host_t *host = arg; + while (!host->stopping) { + pocketjs_host_net_lock(host); + pnet_posix_driver_dispatch(host->driver, host->net); + pnet_runtime_service(host->net); + uint64_t deadline = pnet_runtime_next_deadline_ms(host->net); + bool more = pnet_runtime_has_pending_output(host->net); + pocketjs_host_net_unlock(host); + int timeout = 250; + if (deadline) { + uint64_t now = plat_now_ms(NULL); + timeout = deadline > now ? (int)(deadline - now) : 0; + if (timeout > 250) timeout = 250; + } + if (more) timeout = 0; + if (host->stopping) break; + pnet_posix_driver_wait(host->driver, timeout); + } + task_exit(host, &host->net_done); +} + +/* ------------------------------------------------------------------------ */ +/* Guest task */ +/* ------------------------------------------------------------------------ */ + +/* QuickJS interrupt handler: while stopping, a turn that outlives the budget + * is aborted (QuickJS raises an uncatchable InternalError at the next + * check), so shutdown is bounded whatever the bundle does. */ +static int guest_interrupt(JSRuntime *rt, void *opaque) { + (void)rt; + pocketjs_esp_host_t *host = opaque; + if (!host->stopping) return 0; + uint32_t budget = host->cfg.stop_turn_budget_ms ? host->cfg.stop_turn_budget_ms : 50; + return (esp_timer_get_time() - host->turn_started_us) > (int64_t)budget * 1000; +} + +static void drain_jobs(pocketjs_esp_host_t *host) { + JSContext *ctx; + for (int i = 0; i < 4096; i++) { + int rc = JS_ExecutePendingJob(host->rt, &ctx); + if (rc == 0) break; + host->stats.jobs++; + if (rc < 0) log_exception(ctx ? ctx : host->ctx, "job"); + } +} + +static bool guest_boot(pocketjs_esp_host_t *host) { + host->rt = JS_NewRuntime2(&GUEST_ALLOC, host); + if (!host->rt) { + ESP_LOGE(TAG, "JS_NewRuntime2 failed"); + return false; + } + JS_SetMemoryLimit(host->rt, host->cfg.guest_memory_limit ? host->cfg.guest_memory_limit : 4 * 1024 * 1024); + size_t stack_limit = host->cfg.guest_stack_limit ? host->cfg.guest_stack_limit : (host->cfg.guest_task_stack / 4) * 3; + JS_SetMaxStackSize(host->rt, stack_limit); + JS_UpdateStackTop(host->rt); + JS_SetInterruptHandler(host->rt, guest_interrupt, host); + host->ctx = JS_NewContext(host->rt); + if (!host->ctx) { + ESP_LOGE(TAG, "JS_NewContext failed"); + return false; + } + install_console(host->ctx); + JSValue global = JS_GetGlobalObject(host->ctx); + JS_SetPropertyStr(host->ctx, global, "__simHz", JS_NewUint32(host->ctx, host->cfg.tick_hz)); + JS_SetPropertyStr(host->ctx, global, "frame", JS_UNDEFINED); + JS_FreeValue(host->ctx, global); + if (host->net) pocketjs_host_mount_network(host); + if (host->cfg.before_eval) host->cfg.before_eval(host->ctx, host->cfg.user); + int64_t t0 = esp_timer_get_time(); + host->turn_started_us = t0; + JSValue result = JS_Eval(host->ctx, host->bundle, host->bundle_len, "app.js", JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(result)) { + log_exception(host->ctx, "eval"); + JS_FreeValue(host->ctx, result); + return false; + } + JS_FreeValue(host->ctx, result); + drain_jobs(host); + ESP_LOGI(TAG, "bundle evaluated in %lld us, guest heap %u bytes%s%s", (long long)(esp_timer_get_time() - t0), + (unsigned)host->guest_heap, host->cfg.plan_hash ? ", plan " : "", host->cfg.plan_hash ? host->cfg.plan_hash : ""); + global = JS_GetGlobalObject(host->ctx); + host->frame_fn = JS_GetPropertyStr(host->ctx, global, "frame"); + JS_FreeValue(host->ctx, global); + if (!JS_IsFunction(host->ctx, host->frame_fn)) { + ESP_LOGW(TAG, "bundle installed no globalThis.frame; the host will tick without a guest turn"); + } + return true; +} + +static void guest_frame(pocketjs_esp_host_t *host, uint32_t frame) { + if (host->net) { + pocketjs_host_net_lock(host); + pnet_runtime_begin_tick(host->net); + pocketjs_host_net_unlock(host); + } + int64_t t0 = esp_timer_get_time(); + host->turn_started_us = t0; + if (JS_IsFunction(host->ctx, host->frame_fn)) { + JSValue args[2] = {JS_NewInt32(host->ctx, 0), JS_NewInt32(host->ctx, 0x8080)}; + JSValue global = JS_GetGlobalObject(host->ctx); + JSValue r = JS_Call(host->ctx, host->frame_fn, global, 2, args); + JS_FreeValue(host->ctx, global); + if (JS_IsException(r)) { + host->stats.frame_errors++; + log_exception(host->ctx, "frame"); + } + JS_FreeValue(host->ctx, r); + } + drain_jobs(host); + uint32_t us = (uint32_t)(esp_timer_get_time() - t0); + if (us > host->stats.frame_max_us) host->stats.frame_max_us = us; + if (host->net && host->net_dirty) { + host->net_dirty = false; + pnet_posix_driver_wake(host->driver); + } + host->stats.frames = frame; + if (host->cfg.after_frame) host->cfg.after_frame(frame, host->cfg.user); +} + +/* Sleep until the absolute deadline (µs on the esp_timer clock). The tick + * granularity rounds UP, so a frame starts within one RTOS tick after its + * deadline and never before it; because deadlines are absolute (t0 + k/hz) + * the error does not accumulate and the cadence is exactly tick_hz. */ +static void sleep_until(int64_t deadline_us) { + int64_t wait = deadline_us - esp_timer_get_time(); + if (wait <= 0) return; + const int64_t tick_us = (int64_t)portTICK_PERIOD_MS * 1000; + TickType_t ticks = (TickType_t)((wait + tick_us - 1) / tick_us); + if (ticks == 0) ticks = 1; + vTaskDelay(ticks); +} + +static void guest_task(void *arg) { + pocketjs_esp_host_t *host = arg; + if (!guest_boot(host)) { + host->boot_failed = true; + host->stats.guest_boot_failed = true; + host->stopping = true; + } + const uint32_t hz = host->cfg.tick_hz ? host->cfg.tick_hz : 60; + /* Law 3: one guest turn per tick. A frame that overruns its period makes + * the following ticks late; they still get their turn (run back to back, + * no sleep) until the schedule is caught up. Only a host that falls more + * than max_backlog ticks (0.5 s) behind drops the excess and resyncs — + * the overload guard against a spiral, counted in stats.frames_skipped. */ + const uint64_t max_backlog = hz / 2 ? hz / 2 : 1; + const int64_t t0 = esp_timer_get_time(); + uint64_t k = 0; /* tick index on the absolute schedule */ + uint32_t frame = 0; /* guest turns run */ + while (!host->stopping) { + int64_t deadline = t0 + (int64_t)((k * 1000000ULL) / hz); + int64_t now = esp_timer_get_time(); + if (now > deadline) { + uint64_t behind = (uint64_t)(now - deadline) * hz / 1000000ULL; /* whole ticks late */ + if (behind > max_backlog) { + k += behind; + host->stats.frames_skipped += (uint32_t)behind; + deadline = t0 + (int64_t)((k * 1000000ULL) / hz); + } + } + sleep_until(deadline); /* returns at once when late: the catch-up turn */ + if (host->stopping) break; + guest_frame(host, ++frame); + k++; + } + /* Wind-down: bounded frames so cancellations reach the guest. Each turn is + * bounded by the interrupt handler (stopping is set). */ + if (host->rt && host->ctx) { + if (host->net) { + pocketjs_host_net_lock(host); + pnet_runtime_quiesce(host->net); + pocketjs_host_net_unlock(host); + } + for (int i = 0; i < 4; i++) { + guest_frame(host, ++frame); + vTaskDelay(pdMS_TO_TICKS(1000 / hz ? 1000 / hz : 1)); + } + } + if (host->ctx) { + JS_FreeValue(host->ctx, host->frame_fn); + host->frame_fn = JS_UNDEFINED; + JS_FreeContext(host->ctx); + host->ctx = NULL; + } + if (host->rt) { + JS_FreeRuntime(host->rt); + host->rt = NULL; + } + task_exit(host, &host->guest_done); +} + +/* ------------------------------------------------------------------------ */ +/* Lifecycle */ +/* ------------------------------------------------------------------------ */ + +/* Release everything the host owns. Tasks that were started must already + * have exited (their done flags set): this is the single teardown path for + * a failed start and for stop(). */ +static void host_release(pocketjs_esp_host_t *host) { + if (host->net) pnet_runtime_destroy(host->net); + if (host->tls_provider) pnet_esp_tls_destroy(host->tls_provider); + if (host->driver) pnet_posix_driver_destroy(host->driver); + if (host->net_lock) vSemaphoreDelete(host->net_lock); + free(host); +} + +/* Ask running tasks to stop and wait for them; true when every started task + * has exited and the host may be released. */ +static bool host_join(pocketjs_esp_host_t *host, uint32_t deadline_ms) { + __atomic_store_n(&host->stop_waiter, xTaskGetCurrentTaskHandle(), __ATOMIC_SEQ_CST); + __atomic_store_n(&host->stopping, true, __ATOMIC_SEQ_CST); + if (host->driver) pnet_posix_driver_wake(host->driver); + bool ok = true; + if (host->guest_task && !wait_flag(&host->guest_done, deadline_ms)) ok = false; + if (host->net_task) { + /* Keep waking the network task: its select may have started before + * stopping was visible to it. */ + int64_t end = esp_timer_get_time() + (int64_t)deadline_ms * 1000; + while (!__atomic_load_n(&host->net_done, __ATOMIC_SEQ_CST)) { + if (esp_timer_get_time() >= end) { + ok = false; + break; + } + pnet_posix_driver_wake(host->driver); + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + } + /* Let the idle task reclaim the deleted tasks' stacks before we free + * memory they were allocated next to. */ + if (ok) vTaskDelay(pdMS_TO_TICKS(2)); + return ok; +} + +esp_err_t pocketjs_esp_host_start(const pocketjs_esp_host_config *cfg, const char *bundle, size_t bundle_len, + pocketjs_esp_host_t **out_host) { + if (!cfg || !bundle || !out_host) return ESP_ERR_INVALID_ARG; + pocketjs_esp_host_t *host = calloc(1, sizeof *host); + if (!host) return ESP_ERR_NO_MEM; + host->cfg = *cfg; + host->bundle = bundle; + host->bundle_len = bundle_len; + host->frame_fn = JS_UNDEFINED; + host->stats.plan_hash = cfg->plan_hash ? cfg->plan_hash : ""; + esp_err_t err = ESP_OK; + if (cfg->network_policy_json) { + if (cfg->network_tls && !cfg->wall_clock_trusted) { + ESP_LOGW(TAG, "TLS enabled without a wall_clock_trusted callback: verifying connections fail closed " + "(tls_clock_untrusted) until the board layer provides one"); + } + host->net_lock = xSemaphoreCreateMutex(); + if (!host->net_lock) { err = ESP_ERR_NO_MEM; goto fail; } + host->driver = pnet_posix_driver_create(cfg->network_max_sockets > 0 ? cfg->network_max_sockets : 12); + if (!host->driver) { err = ESP_ERR_NO_MEM; goto fail; } + pnet_platform plat = {host, plat_now_ms, plat_alloc, plat_free, plat_random, plat_log, plat_clock_trusted}; + pnet_runtime_config ncfg; + if (cfg->network_config) ncfg = *cfg->network_config; + else { + pnet_runtime_config_defaults(&ncfg); + /* Host tightening for an MCU profile: queues stay in PSRAM but the + * event/aggregate budgets are modest. */ + ncfg.http_max_inflight = 4; + ncfg.http_default_queue_bytes = 16 * 1024; + ncfg.http_max_queue_bytes = 64 * 1024; + ncfg.http_default_aggregate_bytes = 256 * 1024; + ncfg.http_max_aggregate_bytes = 1024 * 1024; + ncfg.http_max_tick_bytes = 64 * 1024; + ncfg.ws_max_sockets = 4; + ncfg.ws_max_message_bytes = 64 * 1024; + ncfg.ws_max_receive_queue_bytes = 128 * 1024; + ncfg.ws_max_send_queue_bytes = 128 * 1024; + ncfg.ws_send_high_water_bytes = 32 * 1024; + ncfg.ws_send_low_water_bytes = 8 * 1024; + ncfg.ws_max_tick_bytes = 64 * 1024; + ncfg.httpd_max_connections = 8; + ncfg.httpd_max_inflight = 4; + ncfg.httpd_default_request_queue_bytes = 16 * 1024; + ncfg.httpd_max_request_queue_bytes = 64 * 1024; + ncfg.httpd_max_send_queue_bytes = 64 * 1024; + ncfg.httpd_send_high_water_bytes = 32 * 1024; + ncfg.httpd_send_low_water_bytes = 8 * 1024; + ncfg.httpd_max_tick_bytes = 64 * 1024; + ncfg.max_heap_bytes = 1024 * 1024; + ncfg.io_chunk_bytes = 1460; + } + if (cfg->network_tls) { + host->tls_provider = pnet_esp_tls_create(pnet_posix_driver_ops(), host->driver); + if (!host->tls_provider) { + ESP_LOGE(TAG, "ESP-TLS provider creation failed"); + err = ESP_ERR_NO_MEM; + goto fail; + } + host->net = pnet_runtime_create_tls(&plat, pnet_posix_driver_ops(), host->driver, pnet_esp_tls_ops(), + pnet_esp_tls_ctx(host->tls_provider), &ncfg, cfg->network_policy_json); + } else { + host->net = pnet_runtime_create(&plat, pnet_posix_driver_ops(), host->driver, &ncfg, cfg->network_policy_json); + } + if (!host->net) { + ESP_LOGE(TAG, "network runtime creation failed (is network_policy_json the plan's canonical policy?)"); + err = ESP_ERR_INVALID_ARG; + goto fail; + } + if (xTaskCreatePinnedToCore(net_task, "pocketjs-net", cfg->net_task_stack, host, cfg->net_task_priority, + &host->net_task, cfg->net_task_core) != pdPASS) { + ESP_LOGE(TAG, "network task creation failed"); + host->net_task = NULL; + err = ESP_ERR_NO_MEM; + goto fail; + } + } + if (xTaskCreatePinnedToCore(guest_task, "pocketjs-guest", cfg->guest_task_stack, host, cfg->guest_task_priority, + &host->guest_task, cfg->guest_task_core) != pdPASS) { + ESP_LOGE(TAG, "guest task creation failed"); + host->guest_task = NULL; + err = ESP_ERR_NO_MEM; + goto fail; + } + *out_host = host; + return ESP_OK; + +fail: + /* One unwind path: stop whatever task already runs, then release. */ + if (host_join(host, 5000)) host_release(host); + else ESP_LOGE(TAG, "start unwind: a task did not exit; leaking the host rather than freeing under it"); + return err; +} + +void pocketjs_esp_host_stop(pocketjs_esp_host_t *host) { + if (!host) return; + /* Bound: wind-down is 4 frames + the turn in progress, each capped by the + * stop budget; the network task exits within one select timeout. 10 s is + * far beyond that and exists only so a wedged task is detected. */ + if (host_join(host, 10000)) { + host_release(host); + } else { + ESP_LOGE(TAG, "stop: a task did not exit in time; leaking the host rather than freeing under a running task"); + } +} + +void pocketjs_esp_host_stats(pocketjs_esp_host_t *host, pocketjs_esp_host_stats_t *out) { + *out = host->stats; + out->guest_heap_bytes = host->guest_heap; + out->guest_heap_high_water = host->guest_heap_high_water; + out->guest_boot_failed = host->boot_failed; + out->plan_hash = host->cfg.plan_hash ? host->cfg.plan_hash : ""; + if (host->net) { + pocketjs_host_net_lock(host); + out->net_heap_bytes = pnet_runtime_heap_bytes(host->net); + out->net_sockets = pnet_posix_driver_socket_count(host->driver); + pocketjs_host_net_unlock(host); + } +} diff --git a/hosts/esp-idf/components/pocketjs_esp_host/src/host_internal.h b/hosts/esp-idf/components/pocketjs_esp_host/src/host_internal.h new file mode 100644 index 00000000..9614fe53 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/src/host_internal.h @@ -0,0 +1,61 @@ +/* Internal shape of the ESP-IDF host. */ +#ifndef POCKETJS_ESP_HOST_INTERNAL_H +#define POCKETJS_ESP_HOST_INTERNAL_H + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "pnet_posix_driver.h" +#include "pocketjs/esp_host.h" +#include "pocketjs/net/esp_tls_provider.h" + +struct pocketjs_esp_host { + pocketjs_esp_host_config cfg; + const char *bundle; + size_t bundle_len; + /* guest */ + JSRuntime *rt; + JSContext *ctx; + JSValue frame_fn; + size_t guest_heap; + size_t guest_heap_high_water; + TaskHandle_t guest_task; + /* the current guest turn: set before JS_Call/JS_Eval/job drain, read by the + * interrupt handler to bound turns while stopping */ + volatile int64_t turn_started_us; + /* network */ + pnet_runtime *net; + pnet_posix_driver *driver; + pnet_esp_tls *tls_provider; + SemaphoreHandle_t net_lock; + TaskHandle_t net_task; + volatile bool net_dirty; /* an op ran during this frame: wake the network task */ + /* lifecycle — one state machine: + * stopping asked to stop (by stop() or by a failed boot); both task + * loops exit at their next check + * guest_done / net_done + * set by the task as the LAST thing it does with `host`, + * right before it notifies the waiter and deletes itself; + * the owner frees nothing a task uses until its flag is set + * stop_waiter the task blocked in stop()/unwind, notified by exiting tasks + */ + volatile bool stopping; + volatile bool guest_done; + volatile bool net_done; + volatile bool boot_failed; + TaskHandle_t stop_waiter; + pocketjs_esp_host_stats_t stats; +}; + +/* net_binding.c */ +void pocketjs_host_mount_network(pocketjs_esp_host_t *host); + +/* host.c helpers used by the binding */ +static inline void pocketjs_host_net_lock(pocketjs_esp_host_t *host) { + xSemaphoreTake(host->net_lock, portMAX_DELAY); +} +static inline void pocketjs_host_net_unlock(pocketjs_esp_host_t *host) { + xSemaphoreGive(host->net_lock); +} + +#endif diff --git a/hosts/esp-idf/components/pocketjs_esp_host/src/net_binding.c b/hosts/esp-idf/components/pocketjs_esp_host/src/net_binding.c new file mode 100644 index 00000000..945f3ffd --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/src/net_binding.c @@ -0,0 +1,417 @@ +/* `globalThis.net` / `ws` / `httpd` on QuickJS-ng: each op takes the runtime + * lock, forwards to the portable core and marshals the result. Buffers are + * borrowed for the duration of the synchronous call only; the core copies + * everything it keeps. */ +#include "host_internal.h" + +#include + +/* The host pointer travels in the function's magic-less opaque: QuickJS-ng + * C functions get no closure, so keep the active host in the runtime opaque. */ +static pocketjs_esp_host_t *host_of(JSContext *ctx) { + return JS_GetRuntimeOpaque(JS_GetRuntime(ctx)); +} + +/* --- argument helpers ------------------------------------------------------- */ + +static bool arg_i32(JSContext *ctx, JSValueConst v, int32_t *out) { + return JS_ToInt32(ctx, out, v) == 0; +} + +/** Borrow an ArrayBuffer (or null/undefined → NULL with len 0). Returns false + * and throws on any other type. */ +static bool arg_buffer(JSContext *ctx, JSValueConst v, uint8_t **ptr, size_t *len) { + if (JS_IsNull(v) || JS_IsUndefined(v)) { + *ptr = NULL; + *len = 0; + return true; + } + size_t size = 0; + uint8_t *p = JS_GetArrayBuffer(ctx, &size, v); + /* NULL means "not an ArrayBuffer" or "detached": QuickJS left the TypeError + * pending. Zero-length buffers still yield a non-NULL data pointer. */ + if (!p) return false; + *ptr = p; + *len = size; + return true; +} + +/** Slice into a borrowed ArrayBuffer at [offset, offset+length). */ +static bool arg_window(JSContext *ctx, JSValueConst buf, JSValueConst off, JSValueConst len, uint8_t **ptr, size_t *out_len) { + size_t size = 0; + uint8_t *p = JS_GetArrayBuffer(ctx, &size, buf); + if (!p) { + JS_FreeValue(ctx, JS_GetException(ctx)); /* replaced by the caller's RangeError */ + return false; + } + int32_t o, l; + if (!arg_i32(ctx, off, &o) || !arg_i32(ctx, len, &l) || o < 0 || l < 0) return false; + if ((size_t)o > size || (size_t)l > size - (size_t)o) return false; + *ptr = p + o; + *out_len = (size_t)l; + return true; +} + +#define WITH_HOST(name) \ + pocketjs_esp_host_t *host = host_of(ctx); \ + (void)this_val; \ + if (!host || !host->net) return JS_ThrowTypeError(ctx, name ": network runtime unavailable") + +#define LOCKED(expr) \ + do { \ + pocketjs_host_net_lock(host); \ + expr; \ + pocketjs_host_net_unlock(host); \ + host->net_dirty = true; \ + } while (0) + +/* --- net ---------------------------------------------------------------------- */ + +static JSValue net_start(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.start"); + if (argc < 1) return JS_ThrowTypeError(ctx, "net.start(meta, body)"); + const char *meta = JS_ToCString(ctx, argv[0]); + if (!meta) return JS_EXCEPTION; + uint8_t *body = NULL; + size_t body_len = 0; + if (argc >= 2 && !arg_buffer(ctx, argv[1], &body, &body_len)) { + JS_FreeCString(ctx, meta); + return JS_EXCEPTION; + } + int handle; + LOCKED(handle = pnet_http_start(host->net, meta, body, body_len)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, handle); +} + +static JSValue net_cancel(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.cancel"); + int32_t handle; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_UNDEFINED; + LOCKED(pnet_http_cancel(host->net, handle)); + return JS_UNDEFINED; +} + +static JSValue net_poll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.poll"); + (void)argc; + (void)argv; + size_t len = 0; + pocketjs_host_net_lock(host); + /* Two-phase poll: the batch leaves the core only once the guest holds its + * copy. If QuickJS cannot allocate the string the events stay visible and + * are rendered again next tick instead of vanishing. */ + const char *batch = pnet_http_poll_render(host->net, &len); + JSValue out = batch ? JS_NewStringLen(ctx, batch, len) : JS_UNDEFINED; + if (batch && !JS_IsException(out)) pnet_http_poll_consume(host->net); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue net_last_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.lastError"); + (void)argc; + (void)argv; + pocketjs_host_net_lock(host); + JSValue out = JS_NewString(ctx, pnet_http_last_error(host->net)); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue net_read_into(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.readInto"); + int32_t handle; + uint8_t *ptr; + size_t len; + if (argc < 4 || !arg_i32(ctx, argv[0], &handle) || !arg_window(ctx, argv[1], argv[2], argv[3], &ptr, &len)) { + return JS_ThrowRangeError(ctx, "net.readInto(handle, buffer, offset, length)"); + } + int n; + LOCKED(n = pnet_http_read_into(host->net, handle, ptr, len)); + return JS_NewInt32(ctx, n); +} + +static JSValue net_limits(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.limits"); + (void)argc; + (void)argv; + return JS_NewString(ctx, pnet_http_limits(host->net)); +} + +/* --- ws ----------------------------------------------------------------------- */ + +static JSValue ws_connect(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.connect"); + if (argc < 1) return JS_ThrowTypeError(ctx, "ws.connect(meta)"); + const char *meta = JS_ToCString(ctx, argv[0]); + if (!meta) return JS_EXCEPTION; + int handle; + LOCKED(handle = pnet_ws_connect(host->net, meta)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, handle); +} + +static JSValue ws_send(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.send"); + int32_t handle, opcode; + if (argc < 3 || !arg_i32(ctx, argv[0], &handle) || !arg_i32(ctx, argv[1], &opcode)) { + return JS_ThrowTypeError(ctx, "ws.send(handle, opcode, payload)"); + } + const uint8_t *payload = NULL; + size_t len = 0; + const char *text = NULL; + if (JS_IsString(argv[2])) { + text = JS_ToCStringLen(ctx, &len, argv[2]); + if (!text) return JS_EXCEPTION; + payload = (const uint8_t *)text; + } else { + uint8_t *p; + if (!arg_buffer(ctx, argv[2], &p, &len)) return JS_EXCEPTION; + payload = p; + } + int rc; + LOCKED(rc = pnet_ws_send(host->net, handle, opcode, payload, len)); + if (text) JS_FreeCString(ctx, text); + return JS_NewInt32(ctx, rc); +} + +static JSValue ws_receive_into(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.receiveInto"); + int32_t handle; + uint8_t *ptr; + size_t len; + if (argc < 4 || !arg_i32(ctx, argv[0], &handle) || !arg_window(ctx, argv[1], argv[2], argv[3], &ptr, &len)) { + return JS_ThrowRangeError(ctx, "ws.receiveInto(handle, buffer, offset, length)"); + } + int n; + LOCKED(n = pnet_ws_receive_into(host->net, handle, ptr, len)); + return JS_NewInt32(ctx, n); +} + +static JSValue ws_close(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.close"); + int32_t handle, code = 0; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_NewInt32(ctx, -1); + if (argc >= 2 && !JS_IsUndefined(argv[1]) && !arg_i32(ctx, argv[1], &code)) return JS_NewInt32(ctx, -3); + const char *reason = NULL; + size_t reason_len = 0; + if (argc >= 3 && !JS_IsUndefined(argv[2])) { + reason = JS_ToCStringLen(ctx, &reason_len, argv[2]); + if (!reason) return JS_EXCEPTION; + } + int rc; + LOCKED(rc = pnet_ws_close(host->net, handle, code, reason, reason_len)); + if (reason) JS_FreeCString(ctx, reason); + return JS_NewInt32(ctx, rc); +} + +static JSValue ws_terminate(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.terminate"); + int32_t handle; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_UNDEFINED; + LOCKED(pnet_ws_terminate(host->net, handle)); + return JS_UNDEFINED; +} + +static JSValue ws_buffered_amount(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.bufferedAmount"); + int32_t handle; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_NewInt32(ctx, -1); + int n; + pocketjs_host_net_lock(host); + n = pnet_ws_buffered_amount(host->net, handle); + pocketjs_host_net_unlock(host); + return JS_NewInt32(ctx, n); +} + +static JSValue ws_poll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.poll"); + (void)argc; + (void)argv; + size_t len = 0; + pocketjs_host_net_lock(host); + /* Two-phase poll: the batch leaves the core only once the guest holds its + * copy. If QuickJS cannot allocate the string the events stay visible and + * are rendered again next tick instead of vanishing. */ + const char *batch = pnet_ws_poll_render(host->net, &len); + JSValue out = batch ? JS_NewStringLen(ctx, batch, len) : JS_UNDEFINED; + if (batch && !JS_IsException(out)) pnet_ws_poll_consume(host->net); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue ws_last_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.lastError"); + (void)argc; + (void)argv; + pocketjs_host_net_lock(host); + JSValue out = JS_NewString(ctx, pnet_ws_last_error(host->net)); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue ws_limits(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.limits"); + (void)argc; + (void)argv; + return JS_NewString(ctx, pnet_ws_limits(host->net)); +} + +/* --- httpd -------------------------------------------------------------------- */ + +static JSValue httpd_listen(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.listen"); + if (argc < 1) return JS_ThrowTypeError(ctx, "httpd.listen(meta)"); + const char *meta = JS_ToCString(ctx, argv[0]); + if (!meta) return JS_EXCEPTION; + int handle; + LOCKED(handle = pnet_httpd_listen(host->net, meta)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, handle); +} + +static JSValue httpd_stop(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.stop"); + int32_t handle, timeout = 0; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_NewInt32(ctx, -1); + bool graceful = argc >= 2 ? JS_ToBool(ctx, argv[1]) : true; + if (argc >= 3) arg_i32(ctx, argv[2], &timeout); + int rc; + LOCKED(rc = pnet_httpd_stop(host->net, handle, graceful, timeout > 0 ? (uint32_t)timeout : 0)); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_respond(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.respond"); + int32_t req; + if (argc < 2 || !arg_i32(ctx, argv[0], &req)) return JS_ThrowTypeError(ctx, "httpd.respond(req, meta, body)"); + const char *meta = JS_ToCString(ctx, argv[1]); + if (!meta) return JS_EXCEPTION; + uint8_t *body = NULL; + size_t body_len = 0; + if (argc >= 3 && !arg_buffer(ctx, argv[2], &body, &body_len)) { + JS_FreeCString(ctx, meta); + return JS_EXCEPTION; + } + int rc; + LOCKED(rc = pnet_httpd_respond(host->net, req, meta, body, body_len)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.write"); + int32_t req; + uint8_t *chunk; + size_t len; + if (argc < 2 || !arg_i32(ctx, argv[0], &req) || !arg_buffer(ctx, argv[1], &chunk, &len)) { + return JS_ThrowTypeError(ctx, "httpd.write(req, chunk)"); + } + int rc; + LOCKED(rc = pnet_httpd_write(host->net, req, chunk, len)); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_end_body(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.endBody"); + int32_t req; + if (argc < 1 || !arg_i32(ctx, argv[0], &req)) return JS_NewInt32(ctx, -1); + int rc; + LOCKED(rc = pnet_httpd_end_body(host->net, req)); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_read_into(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.readInto"); + int32_t req; + uint8_t *ptr; + size_t len; + if (argc < 4 || !arg_i32(ctx, argv[0], &req) || !arg_window(ctx, argv[1], argv[2], argv[3], &ptr, &len)) { + return JS_ThrowRangeError(ctx, "httpd.readInto(req, buffer, offset, length)"); + } + int n; + LOCKED(n = pnet_httpd_read_into(host->net, req, ptr, len)); + return JS_NewInt32(ctx, n); +} + +static JSValue httpd_abort(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.abort"); + int32_t req; + if (argc < 1 || !arg_i32(ctx, argv[0], &req)) return JS_UNDEFINED; + LOCKED(pnet_httpd_abort(host->net, req)); + return JS_UNDEFINED; +} + +static JSValue httpd_poll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.poll"); + (void)argc; + (void)argv; + size_t len = 0; + pocketjs_host_net_lock(host); + /* Two-phase poll: the batch leaves the core only once the guest holds its + * copy. If QuickJS cannot allocate the string the events stay visible and + * are rendered again next tick instead of vanishing. */ + const char *batch = pnet_httpd_poll_render(host->net, &len); + JSValue out = batch ? JS_NewStringLen(ctx, batch, len) : JS_UNDEFINED; + if (batch && !JS_IsException(out)) pnet_httpd_poll_consume(host->net); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue httpd_last_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.lastError"); + (void)argc; + (void)argv; + pocketjs_host_net_lock(host); + JSValue out = JS_NewString(ctx, pnet_httpd_last_error(host->net)); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue httpd_limits(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.limits"); + (void)argc; + (void)argv; + return JS_NewString(ctx, pnet_httpd_limits(host->net)); +} + +/* --- mount -------------------------------------------------------------------- */ + +typedef struct op_entry { + const char *name; + JSCFunction *fn; + int length; +} op_entry; + +static void mount_namespace(JSContext *ctx, JSValueConst global, const char *name, const op_entry *ops, size_t count) { + JSValue ns = JS_NewObject(ctx); + for (size_t i = 0; i < count; i++) { + JS_SetPropertyStr(ctx, ns, ops[i].name, JS_NewCFunction(ctx, ops[i].fn, ops[i].name, ops[i].length)); + } + JS_SetPropertyStr(ctx, global, name, ns); +} + +void pocketjs_host_mount_network(pocketjs_esp_host_t *host) { + JSContext *ctx = host->ctx; + JS_SetRuntimeOpaque(host->rt, host); + static const op_entry NET_OPS[] = { + {"start", net_start, 2}, {"cancel", net_cancel, 1}, {"poll", net_poll, 0}, + {"lastError", net_last_error, 0}, {"readInto", net_read_into, 4}, {"limits", net_limits, 0}, + }; + static const op_entry WS_OPS[] = { + {"connect", ws_connect, 1}, {"send", ws_send, 3}, {"receiveInto", ws_receive_into, 4}, + {"close", ws_close, 3}, {"terminate", ws_terminate, 1}, {"bufferedAmount", ws_buffered_amount, 1}, + {"poll", ws_poll, 0}, {"lastError", ws_last_error, 0}, {"limits", ws_limits, 0}, + }; + static const op_entry HTTPD_OPS[] = { + {"listen", httpd_listen, 1}, {"stop", httpd_stop, 3}, {"respond", httpd_respond, 3}, + {"write", httpd_write, 2}, {"endBody", httpd_end_body, 1}, {"readInto", httpd_read_into, 4}, + {"abort", httpd_abort, 1}, {"poll", httpd_poll, 0}, {"lastError", httpd_last_error, 0}, + {"limits", httpd_limits, 0}, + }; + JSValue global = JS_GetGlobalObject(ctx); + mount_namespace(ctx, global, "net", NET_OPS, sizeof NET_OPS / sizeof NET_OPS[0]); + if (host->cfg.mount_websocket_client) mount_namespace(ctx, global, "ws", WS_OPS, sizeof WS_OPS / sizeof WS_OPS[0]); + if (host->cfg.mount_http_server) mount_namespace(ctx, global, "httpd", HTTPD_OPS, sizeof HTTPD_OPS / sizeof HTTPD_OPS[0]); + JS_FreeValue(ctx, global); +} diff --git a/hosts/esp-idf/components/pocketjs_net_core/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_core/CMakeLists.txt new file mode 100644 index 00000000..3a48e473 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_core/CMakeLists.txt @@ -0,0 +1,28 @@ +# PocketJS network core (engine/net) as an ESP-IDF component: the portable C +# protocol cores plus the BSD-socket driver compiled against lwIP. Nothing in +# engine/net includes ESP-IDF headers; only the driver's FreeRTOS mutex and +# sdkconfig.h are pulled in through ESP_PLATFORM. +set(PNET_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../engine/net") +get_filename_component(PNET_ROOT "${PNET_ROOT}" ABSOLUTE) + +idf_component_register( + SRCS + "${PNET_ROOT}/src/pnet_util.c" + "${PNET_ROOT}/src/pnet_json.c" + "${PNET_ROOT}/src/pnet_url.c" + "${PNET_ROOT}/src/pnet_policy.c" + "${PNET_ROOT}/src/pnet_http1.c" + "${PNET_ROOT}/src/pnet_runtime.c" + "${PNET_ROOT}/src/pnet_http_client.c" + "${PNET_ROOT}/src/pnet_http_server.c" + "${PNET_ROOT}/src/pnet_ws.c" + "${PNET_ROOT}/drivers/posix/pnet_posix_driver.c" + INCLUDE_DIRS + "${PNET_ROOT}/include" + "${PNET_ROOT}/drivers/posix" + PRIV_INCLUDE_DIRS + "${PNET_ROOT}/src" + REQUIRES lwip + PRIV_REQUIRES freertos) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror -Wno-error=format) diff --git a/hosts/esp-idf/components/pocketjs_net_core/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_core/idf_component.yml new file mode 100644 index 00000000..5b4686a3 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_core/idf_component.yml @@ -0,0 +1,5 @@ +description: PocketJS network core (HTTP client, HTTP server, WebSocket client) over lwIP sockets. +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esptls/CMakeLists.txt new file mode 100644 index 00000000..e5d58fb9 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/pnet_esp_tls.c" + INCLUDE_DIRS "include" + REQUIRES pocketjs_net_core esp-tls mbedtls + PRIV_REQUIRES esp_common) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror -Wno-error=unused-parameter) diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_esptls/idf_component.yml new file mode 100644 index 00000000..fd865928 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/idf_component.yml @@ -0,0 +1,5 @@ +description: ESP-TLS TlsProvider for the PocketJS network core (HTTPS/WSS on ESP-IDF). +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/include/pocketjs/net/esp_tls_provider.h b/hosts/esp-idf/components/pocketjs_net_esptls/include/pocketjs/net/esp_tls_provider.h new file mode 100644 index 00000000..9e3ab838 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/include/pocketjs/net/esp_tls_provider.h @@ -0,0 +1,33 @@ +/* PocketJS network core — ESP-TLS TlsProvider (ESP-IDF). + * + * A `pnet_tls_ops` over ESP-TLS + its default Mbed TLS backend, layered on the + * lwIP sockets the driver already connected. Uses the ESP-IDF certificate + * bundle for host trust, SNI = the authorized hostname, DNS-ID/IP-ID + * hostname verification, TLS 1.2 minimum, non-blocking handshake. It maps + * ESP-TLS/Mbed TLS failures onto the four stable tls_* codes. esp_tls drives + * the handshake over the fd the driver already connected; on close esp_tls + * closes the fd and the driver's own close is a harmless no-op. + */ +#ifndef POCKETJS_NET_ESP_TLS_PROVIDER_H +#define POCKETJS_NET_ESP_TLS_PROVIDER_H + +#include "pocketjs/net/driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pnet_esp_tls pnet_esp_tls; + +/** Create the provider. `driver`/`driver_ctx` are the runtime's; the provider + * calls `native_handle` to reach the lwIP fd. NULL on failure. */ +pnet_esp_tls *pnet_esp_tls_create(const pnet_driver_ops *driver, void *driver_ctx); +void pnet_esp_tls_destroy(pnet_esp_tls *tls); +const pnet_tls_ops *pnet_esp_tls_ops(void); +void *pnet_esp_tls_ctx(pnet_esp_tls *tls); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_ESP_TLS_PROVIDER_H */ diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/src/pnet_esp_tls.c b/hosts/esp-idf/components/pocketjs_net_esptls/src/pnet_esp_tls.c new file mode 100644 index 00000000..5d4b2bdc --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/src/pnet_esp_tls.c @@ -0,0 +1,198 @@ +/* ESP-TLS TlsProvider (see esp_tls_provider.h). */ +#include "pocketjs/net/esp_tls_provider.h" + +#include + +#include "esp_crt_bundle.h" +#include "esp_tls.h" +#include "mbedtls/ssl.h" +#include "pocketjs/net/spec.h" + +#define MAX_SESSIONS 12 + +typedef struct session { + pnet_sock s; + esp_tls_t *tls; + char host[256]; + uint16_t port; + bool in_use; + bool started; +} session; + +struct pnet_esp_tls { + const pnet_driver_ops *driver; + void *driver_ctx; + session sessions[MAX_SESSIONS]; +}; + +static session *session_for(pnet_esp_tls *p, pnet_sock s) { + for (int i = 0; i < MAX_SESSIONS; i++) + if (p->sessions[i].in_use && p->sessions[i].s == s) return &p->sessions[i]; + return NULL; +} + +pnet_esp_tls *pnet_esp_tls_create(const pnet_driver_ops *driver, void *driver_ctx) { + if (!driver || !driver->native_handle) return NULL; + pnet_esp_tls *p = calloc(1, sizeof *p); + if (!p) return NULL; + p->driver = driver; + p->driver_ctx = driver_ctx; + return p; +} + +void pnet_esp_tls_destroy(pnet_esp_tls *p) { + if (!p) return; + for (int i = 0; i < MAX_SESSIONS; i++) { + if (p->sessions[i].in_use && p->sessions[i].tls) esp_tls_conn_destroy(p->sessions[i].tls); + } + free(p); +} + +void *pnet_esp_tls_ctx(pnet_esp_tls *p) { + return p; +} + +static void free_session(session *sess) { + if (sess->tls) { + esp_tls_conn_destroy(sess->tls); /* closes the socket fd */ + sess->tls = NULL; + } + sess->in_use = false; + sess->started = false; +} + +static int op_start(void *ctx, pnet_sock s, const pnet_tls_policy *policy) { + pnet_esp_tls *p = ctx; + int fd = p->driver->native_handle(p->driver_ctx, s); + if (fd < 0) return PNET_IO_ERROR; + session *sess = NULL; + for (int i = 0; i < MAX_SESSIONS; i++) + if (!p->sessions[i].in_use) { sess = &p->sessions[i]; break; } + if (!sess) return PNET_IO_NOMEM; + memset(sess, 0, sizeof *sess); + sess->s = s; + sess->in_use = true; + sess->tls = esp_tls_init(); + if (!sess->tls) { + sess->in_use = false; + return PNET_IO_NOMEM; + } + size_t hlen = policy->server_name ? strlen(policy->server_name) : 0; + if (hlen >= sizeof sess->host) hlen = sizeof sess->host - 1; + if (hlen) memcpy(sess->host, policy->server_name, hlen); + sess->host[hlen] = 0; + /* esp_tls takes the connected fd and drives handshake/read/write over it. + * On close it calls close(fd); the driver's own close() then runs on the + * same network task under the same lock with no fd allocated in between, so + * the second close is a harmless no-op on an already-closed descriptor. */ + esp_tls_set_conn_sockfd(sess->tls, fd); + esp_tls_set_conn_state(sess->tls, ESP_TLS_CONNECTING); + return 0; +} + +/* Map an ESP-TLS/Mbed TLS handshake failure onto a stable code. A hostname + * mismatch is reported precisely; other certificate faults (expired, future, + * untrusted root, self-signed, revoked) are reported as tls_certificate_invalid + * when Mbed TLS exposes the verify flags, and otherwise collapse to + * tls_handshake_failed. In every case the connection fails closed with no + * plaintext fallback; the precise per-fault classification is exercised by the + * desktop OpenSSL conformance suite (engine/net/test/tls_test.c). */ +static const char *classify(esp_tls_t *tls, int *cause) { + esp_tls_error_handle_t eh = NULL; + int esp_code = 0, err_flags = 0; + if (esp_tls_get_error_handle(tls, &eh) == ESP_OK && eh) { + esp_tls_get_and_clear_last_error(eh, &esp_code, &err_flags); + } + /* The certificate verification flags are most reliably read straight from + * the mbedTLS session (the error-handle copy is not always populated on the + * async path). */ + uint32_t flags = (uint32_t)err_flags; + mbedtls_ssl_context *ssl = (mbedtls_ssl_context *)esp_tls_get_ssl_context(tls); + if (ssl) { + uint32_t vr = mbedtls_ssl_get_verify_result(ssl); + if (vr != 0 && vr != 0xFFFFFFFFu) flags |= vr; + } + if (cause) *cause = esp_code ? esp_code : (int)flags; + if (flags != 0) { + if (flags & MBEDTLS_X509_BADCERT_CN_MISMATCH) return PNET_ERROR_TLS_HOSTNAME_MISMATCH; + return PNET_ERROR_TLS_CERTIFICATE_INVALID; /* expired, future, untrusted, revoked, bad key usage */ + } + return PNET_ERROR_TLS_HANDSHAKE_FAILED; +} + +static int op_step(void *ctx, pnet_sock s, pnet_tls_failure *failure) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (!sess || !sess->tls) return -1; + /* non_block = false makes ESP-TLS skip its internal select() on the + * connection (which assumes ESP-TLS did the connect and populated its own + * fd sets). Our socket is already connected and set non-blocking by the + * driver, so mbedtls_ssl_handshake returns WANT_READ/WRITE and ESP-TLS + * reports 0 (pending) — the reactor drives the handshake to completion + * across service passes without ever blocking the network task. */ + esp_tls_cfg_t cfg = { + .crt_bundle_attach = esp_crt_bundle_attach, + .common_name = sess->host[0] ? sess->host : NULL, + .non_block = false, + .timeout_ms = 0, + .is_plain_tcp = false, + .skip_common_name = false, + }; + int rc = esp_tls_conn_new_async(sess->host, (int)strlen(sess->host), sess->port, &cfg, sess->tls); + if (rc == 1) return 1; + if (rc == 0) return 0; /* pending */ + int cause = 0; + failure->code = classify(sess->tls, &cause); + failure->cause = cause; + return -1; +} + +static int map_io(int rc) { + if (rc == ESP_TLS_ERR_SSL_WANT_READ || rc == ESP_TLS_ERR_SSL_WANT_WRITE) return PNET_IO_AGAIN; + if (rc == 0) return PNET_IO_EOF; + return PNET_IO_CLOSED; +} + +static int op_read(void *ctx, pnet_sock s, uint8_t *buf, size_t len) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (!sess || !sess->tls) return PNET_IO_ERROR; + ssize_t rc = esp_tls_conn_read(sess->tls, buf, len); + if (rc > 0) return (int)rc; + return map_io((int)rc); +} + +static int op_write(void *ctx, pnet_sock s, const uint8_t *buf, size_t len) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (!sess || !sess->tls) return PNET_IO_ERROR; + ssize_t rc = esp_tls_conn_write(sess->tls, buf, len); + if (rc > 0) return (int)rc; + return map_io((int)rc); +} + +static unsigned op_interest(void *ctx, pnet_sock s) { + (void)ctx; + (void)s; + /* Mbed TLS non-blocking handshake alternates read/write; keep both armed. */ + return PNET_INTEREST_READ | PNET_INTEREST_WRITE; +} + +static void op_close(void *ctx, pnet_sock s) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (sess) free_session(sess); +} + +static const pnet_tls_ops OPS = { + .start = op_start, + .step = op_step, + .read = op_read, + .write = op_write, + .interest = op_interest, + .close = op_close, +}; + +const pnet_tls_ops *pnet_esp_tls_ops(void) { + return &OPS; +} diff --git a/hosts/esp-idf/examples/net-smoke/app.ts b/hosts/esp-idf/examples/net-smoke/app.ts new file mode 100644 index 00000000..4e899b4c --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/app.ts @@ -0,0 +1,373 @@ +// Network smoke app for the ESP-IDF hosts (AtomS3R / Tab5). Headless: no UI, +// only the frame transaction that delivers network completions. +// +// What it does, in order: +// 1. serve HTTP on :8080 (/hello, /echo, /json, /stream, /status) +// 2. against the Mac peer (tools/net-peer.ts): GET/POST/chunked/redirect/ +// big-body/404/timeout/permission cases + a WebSocket echo session +// 3. against the peer board (when configured): GET /hello + POST /echo, then +// a periodic ping every ~2 s that keeps both boards talking +// +// The host injects `globalThis.__pocketSmoke` before evaluating the bundle. + +import { after } from "@pocketjs/framework/clock"; +import { mountHeadless } from "@pocketjs/framework/headless"; +import { NetworkError, URL, getNetworkLimits } from "@pocketjs/framework/net"; +import { fetch, Response, serve, type Request } from "@pocketjs/framework/net/http"; +import { connect } from "@pocketjs/framework/net/websocket"; + +interface SmokeConfig { + board: string; + selfIp: string; + peerHost: string; + peerPort: number; + macHost: string; + macPort: number; + macWsPort: number; + ping: boolean; + tls: boolean; + tlsHost: string; +} + +const cfg: SmokeConfig = (globalThis as { __pocketSmoke?: SmokeConfig }).__pocketSmoke ?? { + board: "unknown", + selfIp: "0.0.0.0", + peerHost: "", + peerPort: 8080, + macHost: "", + macPort: 8790, + macWsPort: 8791, + ping: true, + tls: false, + tlsHost: "example.com", +}; + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function ok(name: string, condition: boolean, detail = ""): void { + if (condition) { + passed++; + console.log(`PASS ${name}${detail ? " " + detail : ""}`); + } else { + failed++; + failures.push(name); + console.error(`FAIL ${name}${detail ? " " + detail : ""}`); + } +} + +function describeError(error: unknown): string { + if (error instanceof NetworkError) return `${error.code}(${error.category}): ${error.message}`; + return String(error); +} + +// QuickJS ships no TextEncoder/TextDecoder; the smoke only moves ASCII. +function asciiDecode(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); + return out; +} +function asciiEncode(text: string): Uint8Array { + const out = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0x7f; + return out; +} + +// --- 1. HTTP server ----------------------------------------------------------- + +let served = 0; +async function startServer(): Promise { + try { + const server = await serve({ + hostname: "0.0.0.0", + port: 8080, + timeouts: { handlerMs: 10_000, keepAliveMs: 5_000 }, + async fetch(request: Request) { + served++; + const url = new URL(request.url); + switch (url.pathname) { + case "/hello": + return new Response(`hello from ${cfg.board} (${cfg.selfIp}) #${served}`); + case "/echo": { + const body = await request.arrayBuffer(); + return new Response(body, { headers: { "content-type": request.headers.get("content-type") ?? "application/octet-stream", "x-echo-bytes": String(body.byteLength) } }); + } + case "/json": + return Response.json({ board: cfg.board, ip: cfg.selfIp, served, limits: getNetworkLimits().httpServer?.maxInflight }); + case "/stream": { + async function* chunks(): AsyncGenerator { + for (let i = 0; i < 5; i++) yield asciiEncode(`chunk-${i};`); + } + return new Response(chunks() as unknown as AsyncIterable, { headers: { "content-type": "text/plain" } }); + } + case "/status": + return Response.json({ passed, failed, failures, served }); + default: + return new Response("not found", { status: 404 }); + } + }, + error(error) { + console.error("server handler error", describeError(error)); + return new Response("boom", { status: 500 }); + }, + }); + ok("serve listening", server.port === 8080, `at ${server.url}`); + } catch (error) { + ok("serve listening", false, describeError(error)); + } +} + +// --- 2. HTTP client against the Mac peer ------------------------------------- + +async function clientSuite(base: string, tag: string): Promise { + // plain GET + try { + const r = await fetch(`${base}/hello`); + const text = await r.text(); + ok(`${tag} GET /hello`, r.status === 200 && text.length > 0, `${r.status} "${text.slice(0, 40)}"`); + } catch (error) { + ok(`${tag} GET /hello`, false, describeError(error)); + } + // POST echo + try { + const payload = new Uint8Array(1024); + for (let i = 0; i < payload.length; i++) payload[i] = i & 0xff; + const r = await fetch(`${base}/echo`, { method: "POST", body: payload, headers: { "content-type": "application/octet-stream" } }); + const echoed = new Uint8Array(await r.arrayBuffer()); + let same = echoed.length === payload.length; + for (let i = 0; same && i < echoed.length; i++) same = echoed[i] === payload[i]; + ok(`${tag} POST /echo 1 KiB`, r.status === 200 && same, `${r.status} ${echoed.length} bytes`); + } catch (error) { + ok(`${tag} POST /echo 1 KiB`, false, describeError(error)); + } + // JSON + try { + const r = await fetch(`${base}/json`); + const data = await r.json<{ board?: string }>(); + ok(`${tag} GET /json`, r.status === 200 && typeof data === "object", JSON.stringify(data).slice(0, 60)); + } catch (error) { + ok(`${tag} GET /json`, false, describeError(error)); + } + // streaming (chunked) via async iteration + try { + const r = await fetch(`${base}/stream`); + let total = ""; + let chunks = 0; + for await (const chunk of r.body!) { + total += asciiDecode(chunk); + chunks++; + } + ok(`${tag} GET /stream`, r.status === 200 && total.includes("chunk-4;"), `${chunks} reads, ${total.length} bytes`); + } catch (error) { + ok(`${tag} GET /stream`, false, describeError(error)); + } + // 404 is a successful exchange + try { + const r = await fetch(`${base}/missing`); + await r.text(); + ok(`${tag} GET /missing → 404`, r.status === 404, String(r.status)); + } catch (error) { + ok(`${tag} GET /missing → 404`, false, describeError(error)); + } +} + +async function macSuite(): Promise { + const base = `http://${cfg.macHost}:${cfg.macPort}`; + await clientSuite(base, "mac"); + // redirect follow + try { + const r = await fetch(`${base}/redirect`); + const text = await r.text(); + ok("mac redirect follow", r.status === 200 && r.redirected && text.length > 0, `${r.status} redirected=${r.redirected} url=${r.url}`); + } catch (error) { + ok("mac redirect follow", false, describeError(error)); + } + // redirect manual + try { + const r = await fetch(`${base}/redirect`, { redirect: "manual" }); + await r.text(); + ok("mac redirect manual", r.status === 302, String(r.status)); + } catch (error) { + ok("mac redirect manual", false, describeError(error)); + } + // big body with backpressure through a small queue + try { + const t0 = Date.now(); + const r = await fetch(`${base}/big?bytes=200000`, { limits: { queueBytes: 8192 } }); + let total = 0; + let checksum = 0; + for await (const chunk of r.body!) { + total += chunk.length; + for (let i = 0; i < chunk.length; i += 97) checksum = (checksum + chunk[i]) & 0xffff; + } + const ms = Date.now() - t0; + ok("mac GET /big 200 KB", r.status === 200 && total === 200000, `${total} bytes in ${ms} ms (${Math.round(total / 1024 / (ms / 1000))} KiB/s) checksum=${checksum}`); + } catch (error) { + ok("mac GET /big 200 KB", false, describeError(error)); + } + // aggregate limit + try { + const r = await fetch(`${base}/big?bytes=100000`, { limits: { aggregateBytes: 4096 } }); + await r.text(); + ok("mac aggregate limit", false, "text() resolved"); + } catch (error) { + ok("mac aggregate limit", error instanceof NetworkError && error.code === "response_too_large", describeError(error)); + } + // timeout + try { + await fetch(`${base}/slow?ms=3000`, { timeouts: { headersMs: 500 } }); + ok("mac headers timeout", false, "resolved"); + } catch (error) { + ok("mac headers timeout", error instanceof NetworkError && error.code === "timeout", describeError(error)); + } + // permission: an endpoint outside the policy + try { + await fetch(`http://${cfg.macHost}:1/x`); + ok("mac permission_denied", false, "resolved"); + } catch (error) { + ok("mac permission_denied", error instanceof NetworkError && error.code === "permission_denied", describeError(error)); + } + // connection refused on an allowed but closed port (macPort + 1 is the + // WebSocket listener, so use macPort + 2) + try { + await fetch(`http://${cfg.macHost}:${cfg.macPort + 2}/x`); + ok("mac connect refused", false, "resolved"); + } catch (error) { + ok("mac connect refused", error instanceof NetworkError && error.code === "connect", describeError(error)); + } + await wsSuite(); +} + +// --- WebSocket against the Mac peer ----------------------------------------- + +function wsSuite(): Promise { + return new Promise((resolve) => { + const received: string[] = []; + let binaryOk = false; + let pongSeen = false; + let done = false; + const finish = (name: string, condition: boolean, detail: string): void => { + if (done) return; + done = true; + ok(name, condition, detail); + resolve(); + }; + connect(`ws://${cfg.macHost}:${cfg.macWsPort}/echo`, { + protocols: ["smoke.v1"], + timeouts: { connectMs: 5000 }, + socket: { + open(socket) { + console.log(`ws open protocol=${socket.protocol}`); + socket.send("hello ws"); + socket.send(new Uint8Array([1, 2, 3, 4, 5])); + socket.ping(new Uint8Array([9])); + }, + message(socket, data) { + if (typeof data === "string") { + received.push(data); + } else { + binaryOk = data.length === 5 && data[0] === 1 && data[4] === 5; + } + if (received.length >= 1 && binaryOk && pongSeen) socket.close(1000, "done"); + }, + pong(socket, data) { + pongSeen = data.length === 1 && data[0] === 9; + if (received.length >= 1 && binaryOk && pongSeen) socket.close(1000, "done"); + }, + close(_socket, code, reason) { + finish("mac websocket echo", received[0] === "hello ws" && binaryOk && pongSeen && code === 1000, `code=${code} reason=${reason}`); + }, + error(_socket, error) { + console.error("ws error", describeError(error)); + }, + }, + }).catch((error: unknown) => finish("mac websocket echo", false, describeError(error))); + after(15, () => finish("mac websocket echo", false, "timed out")); + }); +} + +// --- 3. Board-to-board ---------------------------------------------------------- + +let pings = 0; +let pingFailures = 0; +async function peerSuite(): Promise { + const base = `http://${cfg.peerHost}:${cfg.peerPort}`; + await clientSuite(base, "peer"); +} + +function schedulePing(): void { + after(2, async () => { + try { + const r = await fetch(`http://${cfg.peerHost}:${cfg.peerPort}/json`); + const data = await r.json<{ board: string; served: number }>(); + pings++; + if (pings % 5 === 1) console.log(`ping #${pings} → ${data.board} served=${data.served} (failures=${pingFailures}, our served=${served})`); + } catch (error) { + pingFailures++; + console.error(`ping failed: ${describeError(error)}`); + } + schedulePing(); + }); +} + +// --- TLS (base .tls: host trust, SNI, hostname verification) -------------------- + +async function tlsSuite(): Promise { + const limits = getNetworkLimits(); + ok("tls advertised", limits.httpClient?.features.includes("tls") === true, JSON.stringify(limits.httpClient?.features)); + // positive control: a real public HTTPS host with a valid chain + try { + const r = await fetch(`https://${cfg.tlsHost}/`, { timeouts: { connectMs: 15000, headersMs: 15000 } }); + const text = await r.text(); + ok(`https ${cfg.tlsHost}`, r.status >= 200 && r.status < 500 && text.length >= 0, `${r.status} ${text.length} bytes`); + } catch (error) { + ok(`https ${cfg.tlsHost}`, false, describeError(error)); + } + const expectTlsError = async (name: string, url: string, code: string): Promise => { + try { + const r = await fetch(url, { timeouts: { connectMs: 15000, headersMs: 15000 } }); + await r.text(); + ok(name, false, `resolved ${r.status}`); + } catch (error) { + ok(name, error instanceof NetworkError && (error.code === code || error.category === "tls"), describeError(error)); + } + }; + await expectTlsError("https expired cert", "https://expired.badssl.com/", "tls_certificate_invalid"); + await expectTlsError("https wrong host", "https://wrong.host.badssl.com/", "tls_hostname_mismatch"); + await expectTlsError("https self-signed", "https://self-signed.badssl.com/", "tls_certificate_invalid"); + await expectTlsError("https untrusted root", "https://untrusted-root.badssl.com/", "tls_certificate_invalid"); +} + +// --- main ----------------------------------------------------------------------- + +mountHeadless(); + +async function main(): Promise { + console.log(`net-smoke on ${cfg.board} ip=${cfg.selfIp} mac=${cfg.macHost || "-"} peer=${cfg.peerHost || "-"}`); + const limits = getNetworkLimits(); + ok("limits mounted", !!limits.httpClient && !!limits.httpServer && !!limits.websocketClient, `httpClient.maxInflight=${limits.httpClient?.maxInflight}`); + await startServer(); + if (cfg.tls) await tlsSuite(); + if (cfg.macHost) await macSuite(); + if (cfg.peerHost) { + // Give the peer time to boot when both boards start together. + for (let attempt = 0; attempt < 30; attempt++) { + try { + const r = await fetch(`http://${cfg.peerHost}:${cfg.peerPort}/hello`, { timeouts: { connectMs: 2000 } }); + await r.text(); + break; + } catch { + await new Promise((resolve) => after(2, resolve)); + } + } + await peerSuite(); + if (cfg.ping) schedulePing(); + } + console.log(`SMOKE ${failed === 0 ? "PASS" : "FAIL"} ${passed}/${passed + failed}${failed ? " failed: " + failures.join(", ") : ""}`); +} + +main().catch((error: unknown) => { + console.error("smoke crashed", describeError(error)); + console.log(`SMOKE FAIL ${passed}/${passed + failed + 1}`); +}); diff --git a/hosts/esp-idf/examples/net-smoke/firmware/CMakeLists.txt b/hosts/esp-idf/examples/net-smoke/firmware/CMakeLists.txt new file mode 100644 index 00000000..de607b7f --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/CMakeLists.txt @@ -0,0 +1,7 @@ +# net-smoke firmware. Build from a copy or a symlinked project directory: +# idf.py -B build set-target esp32s3 && idf.py build flash monitor +cmake_minimum_required(VERSION 3.16) +get_filename_component(POCKETJS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../.." ABSOLUTE) +list(APPEND EXTRA_COMPONENT_DIRS "${POCKETJS_ROOT}/hosts/esp-idf/components") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_smoke) diff --git a/hosts/esp-idf/examples/net-smoke/firmware/main/CMakeLists.txt b/hosts/esp-idf/examples/net-smoke/firmware/main/CMakeLists.txt new file mode 100644 index 00000000..ba67e4d8 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/main/CMakeLists.txt @@ -0,0 +1,53 @@ +# The smoke firmware's guest bundle AND its network policy come from the +# smoke manifest's Build Plan: tools/esp-idf.ts resolves +# hosts/esp-idf/examples/net-smoke/pocket.json (format 3) plus this rig's +# endpoints (Kconfig) against the board's private profile and writes +# app.js, network-policy.json (the canonical ResolvedNetworkPolicy the host +# hands to the core verbatim), plan.json and host-inputs.h (plan hash, +# features) into the build directory. main.c embeds the first two and +# compiles against the header; it authors no policy of its own. +# Set POCKETJS_ROOT / BUN when the defaults do not match the checkout. +if(NOT DEFINED POCKETJS_ROOT) + get_filename_component(POCKETJS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../../.." ABSOLUTE) +endif() +if(NOT DEFINED BUN) + find_program(BUN bun HINTS "$ENV{HOME}/.bun/bin" "/opt/homebrew/bin" "/usr/local/bin") +endif() +set(SMOKE_DIR "${POCKETJS_ROOT}/hosts/esp-idf/examples/net-smoke") +set(SMOKE_APP "${SMOKE_DIR}/app.ts") +set(SMOKE_MANIFEST "${SMOKE_DIR}/pocket.json") +set(SMOKE_OUT "${CMAKE_BINARY_DIR}/pocketjs-app") +set(SMOKE_JS "${SMOKE_OUT}/app.js") +set(SMOKE_POLICY "${SMOKE_OUT}/network-policy.json") +set(SMOKE_HEADER "${SMOKE_OUT}/host-inputs.h") + +# Which private profile: the board name selects atoms3r-dev / tab5-dev. +if(CONFIG_SMOKE_BOARD_NAME STREQUAL "tab5") + set(SMOKE_BOARD "tab5") +else() + set(SMOKE_BOARD "atoms3r") +endif() + +idf_component_register( + SRCS "main.c" + INCLUDE_DIRS "${SMOKE_OUT}" + REQUIRES pocketjs_esp_host pocketjs_board pocketjs_net_core + PRIV_REQUIRES esp_timer heap + EMBED_TXTFILES "${SMOKE_JS}" "${SMOKE_POLICY}") + +add_custom_command( + OUTPUT "${SMOKE_JS}" "${SMOKE_POLICY}" "${SMOKE_HEADER}" + COMMAND "${BUN}" "${POCKETJS_ROOT}/tools/esp-idf.ts" smoke-inputs + "--board=${SMOKE_BOARD}" "--outdir=${SMOKE_OUT}" + "--mac-host=${CONFIG_SMOKE_MAC_HOST}" "--mac-http-port=${CONFIG_SMOKE_MAC_HTTP_PORT}" + "--mac-ws-port=${CONFIG_SMOKE_MAC_WS_PORT}" + "--peer-host=${CONFIG_SMOKE_PEER_HOST}" "--peer-port=${CONFIG_SMOKE_PEER_PORT}" + "--serve-port=${CONFIG_SMOKE_SERVE_PORT}" "--tls-host=${CONFIG_SMOKE_TLS_HOST}" + "--tick-hz=${CONFIG_SMOKE_TICK_HZ}" + WORKING_DIRECTORY "${POCKETJS_ROOT}" + DEPENDS "${SMOKE_APP}" "${SMOKE_MANIFEST}" "${POCKETJS_ROOT}/tools/esp-idf.ts" "${POCKETJS_ROOT}/tools/esp-idf-profile.ts" + "${CMAKE_BINARY_DIR}/config/sdkconfig.h" + COMMENT "PocketJS: resolving the smoke plan (${SMOKE_BOARD}) and bundling ${SMOKE_APP}" + VERBATIM) +add_custom_target(pocketjs_smoke_inputs DEPENDS "${SMOKE_JS}" "${SMOKE_POLICY}" "${SMOKE_HEADER}") +add_dependencies(${COMPONENT_LIB} pocketjs_smoke_inputs) diff --git a/hosts/esp-idf/examples/net-smoke/firmware/main/Kconfig.projbuild b/hosts/esp-idf/examples/net-smoke/firmware/main/Kconfig.projbuild new file mode 100644 index 00000000..104e1a02 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/main/Kconfig.projbuild @@ -0,0 +1,63 @@ +menu "PocketJS network smoke" + + config SMOKE_BOARD_NAME + string "Board name reported by the guest" + default "esp32" + + config SMOKE_WIFI_SSID + string "Wi-Fi SSID" + default "" + + config SMOKE_WIFI_PASSWORD + string "Wi-Fi password" + default "" + + config SMOKE_MAC_HOST + string "Workstation peer address (tools/net-peer.ts); empty disables" + default "" + + config SMOKE_MAC_HTTP_PORT + int "Workstation peer HTTP port" + default 8790 + + config SMOKE_MAC_WS_PORT + int "Workstation peer WebSocket port" + default 8791 + + config SMOKE_PEER_HOST + string "Peer board address; empty disables the board-to-board suite" + default "" + + config SMOKE_PEER_PORT + int "Peer board HTTP port" + default 8080 + + config SMOKE_PEER_PING + bool "Ping the peer board every ~2 s after the suite" + default y + + config SMOKE_SERVE_PORT + int "Port the guest serves on" + default 8080 + + config SMOKE_TICK_HZ + int "Guest tick rate" + default 60 + + config SMOKE_GUEST_MEMORY_KB + int "QuickJS memory limit (KiB)" + default 4096 + + config SMOKE_GUEST_STACK_KB + int "Guest owner task stack (KiB)" + default 32 + + config SMOKE_ENABLE_TLS + bool "Run the HTTPS/TLS suite against public hosts (needs internet + SNTP)" + default n + + config SMOKE_TLS_HOST + string "Public HTTPS host for the positive TLS check" + default "example.com" + +endmenu diff --git a/hosts/esp-idf/examples/net-smoke/firmware/main/main.c b/hosts/esp-idf/examples/net-smoke/firmware/main/main.c new file mode 100644 index 00000000..e302e8fe --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/main/main.c @@ -0,0 +1,124 @@ +/* net-smoke firmware: bring Wi-Fi up, start the PocketJS host with the + * network modules, evaluate the embedded smoke bundle, report stats. + * + * Everything the host mounts and allows comes from the smoke manifest's + * Build Plan (tools/esp-idf.ts, run by main/CMakeLists.txt): the embedded + * network-policy.json is the canonical ResolvedNetworkPolicy of that plan, + * host-inputs.h carries the plan hash and the resolved features, app.js is + * the bundle built against the same plan. This file authors no policy; the + * rig's addresses (Kconfig) reach the guest only as test configuration. */ +#include +#include + +#include "esp_chip_info.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "host-inputs.h" +#include "pocketjs/board.h" +#include "pocketjs/esp_host.h" +#include "sdkconfig.h" + +static const char *TAG = "smoke"; + +extern const char app_js_start[] asm("_binary_app_js_start"); +extern const char app_js_end[] asm("_binary_app_js_end"); +extern const char network_policy_json_start[] asm("_binary_network_policy_json_start"); +extern const char network_policy_json_end[] asm("_binary_network_policy_json_end"); + +static char s_self_ip[16] = "0.0.0.0"; +/* The embedded policy text, NUL-terminated for the core (EMBED_TXTFILES adds + * the NUL; the trailing newline is JSON whitespace). */ +static char s_policy[1024]; + +static void install_smoke_config(JSContext *ctx, void *user) { + (void)user; + char json[512]; + snprintf(json, sizeof json, + "({\"board\":\"%s\",\"selfIp\":\"%s\",\"peerHost\":\"%s\",\"peerPort\":%d,\"macHost\":\"%s\",\"macPort\":%d," + "\"macWsPort\":%d,\"ping\":%s,\"tls\":%s,\"tlsHost\":\"%s\"})", + CONFIG_SMOKE_BOARD_NAME, s_self_ip, CONFIG_SMOKE_PEER_HOST, CONFIG_SMOKE_PEER_PORT, CONFIG_SMOKE_MAC_HOST, + CONFIG_SMOKE_MAC_HTTP_PORT, CONFIG_SMOKE_MAC_WS_PORT, CONFIG_SMOKE_PEER_PING ? "true" : "false", +#if CONFIG_SMOKE_ENABLE_TLS + "true", CONFIG_SMOKE_TLS_HOST); +#else + "false", ""); +#endif + JSValue value = JS_Eval(ctx, json, strlen(json), "smoke-config", JS_EVAL_TYPE_GLOBAL); + JSValue global = JS_GetGlobalObject(ctx); + JS_SetPropertyStr(ctx, global, "__pocketSmoke", value); + JS_FreeValue(ctx, global); +} + +static void report(uint32_t frame, void *user) { + pocketjs_esp_host_t **host = user; + if (frame % (POCKETJS_TICK_HZ * 30) != 0 || !*host) return; /* every 30 s of guest turns */ + pocketjs_esp_host_stats_t st; + pocketjs_esp_host_stats(*host, &st); + ESP_LOGI(TAG, + "frames=%u skipped=%u jobs=%u frameErrors=%u frameMax=%uus guestHeap=%u/%u netHeap=%u sockets=%d " + "freeInternal=%u freePsram=%u uptime=%llus", + (unsigned)st.frames, (unsigned)st.frames_skipped, (unsigned)st.jobs, (unsigned)st.frame_errors, + (unsigned)st.frame_max_us, (unsigned)st.guest_heap_bytes, (unsigned)st.guest_heap_high_water, + (unsigned)st.net_heap_bytes, st.net_sockets, (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), (unsigned long long)(esp_timer_get_time() / 1000000)); +} + +static pocketjs_esp_host_t *s_host; + +void app_main(void) { + esp_chip_info_t chip; + esp_chip_info(&chip); + ESP_LOGI(TAG, "%s (%s, rev v%d.%d) free internal %u, psram %u", CONFIG_SMOKE_BOARD_NAME, CONFIG_IDF_TARGET, + chip.revision / 100, chip.revision % 100, (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); + ESP_LOGI(TAG, "plan %s (target %s, host ABI %d)", POCKETJS_PLAN_HASH, POCKETJS_TARGET, POCKETJS_HOST_ABI); + + pocketjs_board_wifi_config wifi = {.ssid = CONFIG_SMOKE_WIFI_SSID, .password = CONFIG_SMOKE_WIFI_PASSWORD, .timeout_ms = 60000}; + esp_ip4_addr_t ip; + while (pocketjs_board_wifi_connect(&wifi, &ip) != ESP_OK) { + ESP_LOGW(TAG, "retrying Wi-Fi"); + vTaskDelay(pdMS_TO_TICKS(2000)); + } + pocketjs_board_ip_text(s_self_ip, sizeof s_self_ip); + ESP_LOGI(TAG, "station ip %s, serving http://%s:%d/", s_self_ip, s_self_ip, CONFIG_SMOKE_SERVE_PORT); +#if CONFIG_SMOKE_ENABLE_TLS + if (pocketjs_board_sync_time(20000) != ESP_OK) + ESP_LOGW(TAG, "wall clock untrusted: every verifying TLS connection will fail closed with tls_clock_untrusted"); +#endif + + size_t policy_len = (size_t)(network_policy_json_end - network_policy_json_start); + if (policy_len >= sizeof s_policy) { + ESP_LOGE(TAG, "embedded policy is %u bytes, larger than the %u byte buffer", (unsigned)policy_len, (unsigned)sizeof s_policy); + return; + } + memcpy(s_policy, network_policy_json_start, policy_len); + s_policy[policy_len] = 0; + ESP_LOGI(TAG, "policy %s", s_policy); + + pocketjs_esp_host_config cfg; + pocketjs_esp_host_config_defaults(&cfg); + cfg.tick_hz = POCKETJS_TICK_HZ; + cfg.network_policy_json = s_policy; + cfg.plan_hash = POCKETJS_PLAN_HASH; + /* Roles follow the plan's features, not a host opinion. */ + cfg.mount_websocket_client = POCKETJS_FEATURE_NETWORK_WEBSOCKET_CLIENT; + cfg.mount_http_server = POCKETJS_FEATURE_NETWORK_HTTP_SERVER; +#if CONFIG_SMOKE_ENABLE_TLS + cfg.network_tls = POCKETJS_FEATURE_NETWORK_HTTP_CLIENT_TLS; +#endif + cfg.wall_clock_trusted = pocketjs_board_clock_trusted_cb; + cfg.guest_in_psram = true; + cfg.guest_memory_limit = CONFIG_SMOKE_GUEST_MEMORY_KB * 1024; + cfg.guest_task_stack = CONFIG_SMOKE_GUEST_STACK_KB * 1024; + cfg.before_eval = install_smoke_config; + cfg.after_frame = report; + cfg.user = &s_host; + size_t bundle_len = (size_t)(app_js_end - app_js_start); + if (bundle_len > 0 && app_js_start[bundle_len - 1] == 0) bundle_len--; /* EMBED_TXTFILES adds a NUL */ + ESP_LOGI(TAG, "starting the guest with a %u byte bundle", (unsigned)bundle_len); + ESP_ERROR_CHECK(pocketjs_esp_host_start(&cfg, app_js_start, bundle_len, &s_host)); +} diff --git a/hosts/esp-idf/examples/net-smoke/firmware/partitions.csv b/hosts/esp-idf/examples/net-smoke/firmware/partitions.csv new file mode 100644 index 00000000..508c9a43 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x3f0000, diff --git a/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults new file mode 100644 index 00000000..27164c06 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults @@ -0,0 +1,18 @@ +# Common to both profiles. +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_ESP_TASK_WDT_TIMEOUT_S=30 +# lwIP: loopback for the driver's wake socket, a few more sockets, IPv4-only paths first +CONFIG_LWIP_NETIF_LOOPBACK=y +CONFIG_LWIP_MAX_SOCKETS=16 +CONFIG_LWIP_SO_REUSE=y +CONFIG_LWIP_TCP_MSL=5000 +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +# TLS off by default: keep mbedTLS out of the network core's path (Wi-Fi WPA2 still uses it) +CONFIG_ESP_TLS_INSECURE=n +# Log the guest at info level +CONFIG_LOG_DEFAULT_LEVEL_INFO=y diff --git a/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32p4 b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32p4 new file mode 100644 index 00000000..e83a38c3 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32p4 @@ -0,0 +1,18 @@ +# Tab5: ESP32-P4 rev 1.3 + ESP32-C6 over SDIO (esp_hosted + esp_wifi_remote). +# The revision-range and reset-polarity options below are what that silicon needs. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_100=y +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_HEX=y +CONFIG_SPIRAM_SPEED_200M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536 +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_360=y +CONFIG_ESP_WIFI_REMOTE_ENABLED=y +CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE=y +CONFIG_ESP32P4_TAB5_C6_BOARD=y +CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y +# CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_LOW is not set +CONFIG_SMOKE_BOARD_NAME="tab5" diff --git a/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32s3 b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32s3 new file mode 100644 index 00000000..ad7045d4 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32s3 @@ -0,0 +1,14 @@ +# AtomS3R: ESP32-S3-PICO-1-N8R8 (8 MB flash, 8 MB octal PSRAM). +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536 +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y +CONFIG_SMOKE_BOARD_NAME="atoms3r" diff --git a/hosts/esp-idf/examples/net-smoke/pocket.json b/hosts/esp-idf/examples/net-smoke/pocket.json new file mode 100644 index 00000000..48800bd0 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/pocket.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-3.json", + "pocket": 3, + "id": "dev.pocket-stack.net-smoke", + "name": "net-smoke", + "title": "PocketJS network smoke", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": [ + "network.http.client", + "network.http.server", + "network.websocket.client" + ], + "enhances": [ + "network.http.client.tls" + ] + } + }, + "app": { + "entry": "app.ts", + "output": "app", + "framework": "solid", + "viewport": { + "logical": [128, 128], + "presentation": "native" + } + }, + "permissions": { + "network": { + "connect": [ + { "protocol": "https", "host": "example.com", "port": 443 }, + { "protocol": "https", "host": "expired.badssl.com", "port": 443 }, + { "protocol": "https", "host": "wrong.host.badssl.com", "port": 443 }, + { "protocol": "https", "host": "self-signed.badssl.com", "port": 443 }, + { "protocol": "https", "host": "untrusted-root.badssl.com", "port": 443 } + ], + "listen": [ + { "protocol": "http", "address": "0.0.0.0", "port": 8080 } + ], + "credentials": [], + "localNetwork": true, + "insecureTransport": true, + "allowInvalidTlsForDevelopment": false + } + } +} diff --git a/tests/esp-idf-profile.test.ts b/tests/esp-idf-profile.test.ts new file mode 100644 index 00000000..08708177 --- /dev/null +++ b/tests/esp-idf-profile.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { canonicalNetworkPolicyJson, parseNetworkPolicyJson } from "../contracts/spec/network-policy.ts"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { validatePocketManifest } from "../framework/src/manifest/validate.ts"; +import { hostInputsHeader, smokeManifest, type SmokeRig } from "../tools/esp-idf.ts"; +import { + ATOMS3R_DEV_TARGET_ID, + ESP_IDF_DEV_CONTRACTS, + ESP_IDF_DEV_HOST_ABI, + ESP_IDF_NETWORK_CAPABILITIES, + TAB5_DEV_TARGET_ID, + resolveEspIdfBuildPlan, +} from "../tools/esp-idf-profile.ts"; + +const REPOSITORY = fileURLToPath(new URL("../", import.meta.url)); +const MANIFEST_PATH = join(REPOSITORY, "hosts/esp-idf/examples/net-smoke/pocket.json"); + +function smokeBase(): Record { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); +} + +const RIG: SmokeRig = { + board: "atoms3r", + macHost: "172.16.10.225", + macHttpPort: 8790, + macWsPort: 8791, + peerHost: "172.16.10.145", + peerPort: 8080, + servePort: 8080, + tlsHost: "example.com", + tickHz: 60, +}; + +describe("private ESP-IDF network-host profiles", () => { + test("stay private and advertise exactly the hardware-proven network roles", () => { + expect(POCKET_TARGETS).not.toHaveProperty(ATOMS3R_DEV_TARGET_ID); + expect(POCKET_TARGETS).not.toHaveProperty(TAB5_DEV_TARGET_ID); + for (const id of [ATOMS3R_DEV_TARGET_ID, TAB5_DEV_TARGET_ID] as const) { + const profile = ESP_IDF_DEV_CONTRACTS.targets[id]; + expect(profile.hostAbi).toBe(ESP_IDF_DEV_HOST_ABI); + expect(profile.platform).toBe("esp-idf"); + expect(profile.capabilities).toEqual(ESP_IDF_NETWORK_CAPABILITIES); + // No server TLS, no input, no text: the host does not implement them. + expect(profile.capabilities).not.toContain("network.http.server.tls"); + expect(profile.capabilities.some((c: string) => c.startsWith("input.") || c.startsWith("text."))).toBe(false); + } + }); + + test("the smoke manifest is format 3 and resolves on both boards with the rig's endpoints merged", () => { + const base = smokeBase(); + expect(base.pocket).toBe(3); + expect(validatePocketManifest(base).ok).toBe(true); + for (const board of ["atoms3r", "tab5"] as const) { + const manifest = smokeManifest(base, { ...RIG, board }); + const plan = resolveEspIdfBuildPlan(manifest, board); + expect(verifyPlanHash(plan)).toBe(true); + expect(plan.target.id).toBe(board === "tab5" ? TAB5_DEV_TARGET_ID : ATOMS3R_DEV_TARGET_ID); + expect(plan.viewport.logical).toEqual(board === "tab5" ? [1280, 720] : [128, 128]); + expect(plan.features).toEqual({ + "network.http.client": true, + "network.http.client.tls": true, + "network.http.server": true, + "network.websocket.client": true, + }); + // The policy is the plan's: rig endpoints + the manifest's TLS hosts, + // canonical and sorted, the serve port as the only listen rule. + expect(plan.network.connect).toEqual([ + { protocol: "http", host: "172.16.10.145", port: 8080 }, + { protocol: "http", host: "172.16.10.225", port: { min: 8790, max: 8792 } }, + { protocol: "https", host: "example.com", port: 443 }, + { protocol: "https", host: "expired.badssl.com", port: 443 }, + { protocol: "https", host: "self-signed.badssl.com", port: 443 }, + { protocol: "https", host: "untrusted-root.badssl.com", port: 443 }, + { protocol: "https", host: "wrong.host.badssl.com", port: 443 }, + { protocol: "ws", host: "172.16.10.225", port: 8791 }, + ]); + expect(plan.network.listen).toEqual([{ protocol: "http", address: "0.0.0.0", port: 8080 }]); + expect(plan.network.insecureTransport).toBe(true); + expect(plan.network.localNetwork).toBe(true); + expect(plan.network.allowInvalidTlsForDevelopment).toBe(false); + } + }); + + test("the firmware inputs are the plan's projection: canonical policy JSON and a header of plan facts", () => { + const plan = resolveEspIdfBuildPlan(smokeManifest(smokeBase(), RIG), "atoms3r"); + const inputs = extractHostBuildInputs(plan); + // What main.c embeds and hands to pnet_runtime_create verbatim. + expect(inputs.network.policyJson).toBe(canonicalNetworkPolicyJson(plan.network)); + expect(parseNetworkPolicyJson(inputs.network.policyJson)).toEqual(plan.network); + const header = hostInputsHeader(inputs, RIG); + expect(header).toContain(`#define POCKETJS_PLAN_HASH "${plan.planHash}"`); + expect(header).toContain('#define POCKETJS_TARGET "atoms3r-dev"'); + expect(header).toContain("#define POCKETJS_TICK_HZ 60"); + expect(header).toContain("#define POCKETJS_FEATURE_NETWORK_HTTP_SERVER 1"); + expect(header).toContain("#define POCKETJS_FEATURE_NETWORK_WEBSOCKET_CLIENT 1"); + expect(header).toContain("#define POCKETJS_FEATURE_NETWORK_HTTP_CLIENT_TLS 1"); + expect(header).not.toContain("SERVER_TLS"); + }); + + test("a rig without peers still resolves (the suite skips what is not configured)", () => { + const manifest = smokeManifest(smokeBase(), { ...RIG, macHost: undefined, peerHost: undefined, tlsHost: undefined }); + const plan = resolveEspIdfBuildPlan(manifest, "atoms3r"); + expect(plan.network.connect.every((rule) => rule.protocol === "https")).toBe(true); + expect(plan.network.listen).toEqual([{ protocol: "http", address: "0.0.0.0", port: 8080 }]); + }); +}); diff --git a/tools/esp-idf-profile.ts b/tools/esp-idf-profile.ts new file mode 100644 index 00000000..2596c65e --- /dev/null +++ b/tools/esp-idf-profile.ts @@ -0,0 +1,97 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan, type ResolveBuildRequest } from "../framework/src/manifest/resolve.ts"; + +/** + * Private ESP-IDF network-host profiles, used only by the hardware gate + * (hosts/esp-idf/examples/net-smoke). They deliberately stay out of the + * production `POCKET_TARGETS` registry: the ESP-IDF host ships the network + * modules, not a renderer, so these profiles advertise exactly the roles the + * AtomS3R and Tab5 hosts implemented and passed on hardware — the HTTP + * client (with ESP-TLS), the HTTP server (plaintext) and the WebSocket + * client (with ESP-TLS) — and nothing about input or text. The display + * facts are the boards' panels; the smoke firmware is headless and never + * presents, but a plan names the panel the build was made for. + * + * The point of the profile is the plan: the smoke manifest (format 3) + * resolves against it, the resolver normalizes `permissions.network` into + * the plan, and the firmware embeds that canonical policy — the host never + * authors one. + */ +export const ESP_IDF_DEV_HOST_ABI = 9; +export const ATOMS3R_DEV_TARGET_ID = "atoms3r-dev"; +export const TAB5_DEV_TARGET_ID = "tab5-dev"; +export const ATOMS3R_VIEWPORT = [128, 128] as const; +export const TAB5_VIEWPORT = [1280, 720] as const; + +export const ESP_IDF_NETWORK_CAPABILITIES = [ + "network.http.client", + "network.http.client.tls", + "network.http.server", + "network.websocket.client", + "network.websocket.client.tls", +] as const; + +export const ESP_IDF_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [ATOMS3R_DEV_TARGET_ID]: { + hostAbi: ESP_IDF_DEV_HOST_ABI, + platform: "esp-idf", + form: "takeover", + display: { + physicalViewport: ATOMS3R_VIEWPORT, + logicalViewports: [ATOMS3R_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ESP_IDF_NETWORK_CAPABILITIES, + }, + [TAB5_DEV_TARGET_ID]: { + hostAbi: ESP_IDF_DEV_HOST_ABI, + platform: "esp-idf", + form: "takeover", + display: { + physicalViewport: TAB5_VIEWPORT, + logicalViewports: [TAB5_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ESP_IDF_NETWORK_CAPABILITIES, + }, + }), +); + +export type EspIdfBoard = "atoms3r" | "tab5"; + +export function espIdfTargetId(board: EspIdfBoard): string { + return board === "tab5" ? TAB5_DEV_TARGET_ID : ATOMS3R_DEV_TARGET_ID; +} + +export function espIdfPanel(board: EspIdfBoard): readonly [number, number] { + return board === "tab5" ? TAB5_VIEWPORT : ATOMS3R_VIEWPORT; +} + +export function resolveEspIdfBuildPlan( + input: unknown, + board: EspIdfBoard, + options: Omit = {}, +): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: espIdfTargetId(board), ...options }, + ESP_IDF_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket esp-idf: manifest did not resolve for ${board}: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/esp-idf.ts b/tools/esp-idf.ts new file mode 100644 index 00000000..5db1618d --- /dev/null +++ b/tools/esp-idf.ts @@ -0,0 +1,154 @@ +// tools/esp-idf.ts — build inputs for the ESP-IDF network hosts. +// +// bun tools/esp-idf.ts smoke-inputs --board=atoms3r|tab5 --outdir= +// [--mac-host=H --mac-http-port=P --mac-ws-port=P] +// [--peer-host=H --peer-port=P] [--serve-port=P] [--tls-host=H] +// [--tick-hz=N] [--no-bundle] +// +// The smoke firmware's CMake runs this before compiling. It turns the smoke +// manifest (hosts/esp-idf/examples/net-smoke/pocket.json, format 3) plus the +// rig's endpoints (Kconfig: workstation peer, peer board, serve port, TLS +// host) into one resolved manifest, resolves the Build Plan against the +// board's private profile (tools/esp-idf-profile.ts), and writes into +// : +// +// pocket.resolved.json the manifest the plan was resolved from +// plan.json the ResolvedBuildPlan (planHash covers the policy) +// network-policy.json HostBuildInputs.network.policyJson — the canonical +// ResolvedNetworkPolicy the firmware embeds and hands +// to pnet_runtime_create verbatim +// host-inputs.h C defines: plan hash, target, features, tick rate +// app.js the guest bundle (tools/build.ts --plan) +// +// The firmware never authors a policy: everything it mounts and allows comes +// from these files, and planHash names the build on the device. + +import { mkdirSync } from "node:fs"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { NetworkConnectRule, NetworkListenRule } from "../contracts/spec/network-policy.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { espIdfPanel, resolveEspIdfBuildPlan, type EspIdfBoard } from "./esp-idf-profile.ts"; + +const ROOT = resolvePath(fileURLToPath(new URL("..", import.meta.url))); +const SMOKE_DIR = join(ROOT, "hosts/esp-idf/examples/net-smoke"); + +export interface SmokeRig { + readonly board: EspIdfBoard; + readonly macHost?: string; + readonly macHttpPort: number; + readonly macWsPort: number; + readonly peerHost?: string; + readonly peerPort: number; + readonly servePort: number; + readonly tlsHost?: string; + readonly tickHz: number; +} + +/** The smoke manifest with the rig's endpoints merged into its intent: + * workstation peer HTTP (its port and the two after it — the WebSocket + * listener and a closed port for the connection-refused case), workstation + * WebSocket, peer board HTTP, the serve port, the positive TLS host, and the + * board's panel as the (nominal, headless) viewport. */ +export function smokeManifest(base: Record, rig: SmokeRig): Record { + const manifest = structuredClone(base); + const network = (manifest.permissions ??= {}).network ??= {}; + const connect: NetworkConnectRule[] = [...(network.connect ?? [])]; + if (rig.macHost) { + connect.push({ protocol: "http", host: rig.macHost, port: { min: rig.macHttpPort, max: rig.macHttpPort + 2 } }); + connect.push({ protocol: "ws", host: rig.macHost, port: rig.macWsPort }); + } + if (rig.peerHost) connect.push({ protocol: "http", host: rig.peerHost, port: rig.peerPort }); + if (rig.tlsHost && !connect.some((rule) => rule.protocol === "https" && rule.host === rig.tlsHost && rule.port === 443)) { + connect.push({ protocol: "https", host: rig.tlsHost, port: 443 }); + } + network.connect = connect; + const listen: NetworkListenRule[] = [{ protocol: "http", address: "0.0.0.0", port: rig.servePort }]; + network.listen = listen; + const panel = espIdfPanel(rig.board); + manifest.app.viewport = { logical: [panel[0], panel[1]], presentation: "native" }; + return manifest; +} + +/** C header with the plan facts the firmware compiles against. */ +export function hostInputsHeader(inputs: ReturnType, rig: SmokeRig): string { + const define = (name: string, value: string | number) => `#define ${name} ${typeof value === "number" ? value : JSON.stringify(value)}`; + const feature = (id: string) => define(`POCKETJS_FEATURE_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`, inputs.features[id] ? 1 : 0); + return [ + "/* GENERATED by tools/esp-idf.ts from the smoke manifest's Build Plan — do not edit. */", + "#ifndef POCKETJS_HOST_INPUTS_H", + "#define POCKETJS_HOST_INPUTS_H", + define("POCKETJS_PLAN_HASH", inputs.planHash), + define("POCKETJS_TARGET", inputs.target), + define("POCKETJS_HOST_ABI", inputs.hostAbi), + define("POCKETJS_APP_OUTPUT", inputs.appOutput), + define("POCKETJS_TICK_HZ", rig.tickHz), + ...Object.keys(inputs.features).sort().map(feature), + "#endif", + "", + ].join("\n"); +} + +function flag(args: string[], name: string): string | undefined { + const prefix = `--${name}=`; + const hit = args.find((a) => a.startsWith(prefix)); + return hit?.slice(prefix.length); +} + +function intFlag(args: string[], name: string, fallback: number): number { + const raw = flag(args, name); + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) throw new Error(`pocket esp-idf: --${name} must be a non-negative integer`); + return value; +} + +async function smokeInputs(args: string[]): Promise { + const board = flag(args, "board"); + if (board !== "atoms3r" && board !== "tab5") throw new Error("pocket esp-idf: --board=atoms3r|tab5 is required"); + const outdir = flag(args, "outdir"); + if (!outdir) throw new Error("pocket esp-idf: --outdir= is required"); + const rig: SmokeRig = { + board, + macHost: flag(args, "mac-host") || undefined, + macHttpPort: intFlag(args, "mac-http-port", 8790), + macWsPort: intFlag(args, "mac-ws-port", 8791), + peerHost: flag(args, "peer-host") || undefined, + peerPort: intFlag(args, "peer-port", 8080), + servePort: intFlag(args, "serve-port", 8080), + tlsHost: flag(args, "tls-host") || undefined, + tickHz: intFlag(args, "tick-hz", 60), + }; + const base = await Bun.file(join(SMOKE_DIR, "pocket.json")).json(); + const manifest = smokeManifest(base, rig); + const plan = resolveEspIdfBuildPlan(manifest, board); + const inputs = extractHostBuildInputs(plan); + const out = resolvePath(outdir); + mkdirSync(out, { recursive: true }); + await Bun.write(join(out, "pocket.resolved.json"), JSON.stringify(manifest, null, 2) + "\n"); + await Bun.write(join(out, "plan.json"), JSON.stringify(plan, null, 2) + "\n"); + await Bun.write(join(out, "network-policy.json"), inputs.network.policyJson + "\n"); + await Bun.write(join(out, "host-inputs.h"), hostInputsHeader(inputs, rig)); + if (!args.includes("--no-bundle")) { + const build = Bun.spawnSync( + ["bun", join(ROOT, "tools/build.ts"), `--plan=${join(out, "plan.json")}`, `--project-root=${SMOKE_DIR}`, `--outdir=${out}`, `--hz=${rig.tickHz}`], + { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, + ); + if (build.exitCode !== 0) throw new Error(`pocket esp-idf: bundle build failed (${build.exitCode})`); + } + console.log(`pocket esp-idf: ${board} plan ${plan.planHash.slice(0, 23)}… → ${out}`); +} + +if (import.meta.main) { + const [command, ...rest] = process.argv.slice(2); + try { + if (command === "smoke-inputs") await smokeInputs(rest); + else { + console.error("usage: bun tools/esp-idf.ts smoke-inputs --board=atoms3r|tab5 --outdir= [--mac-host=H ...]"); + process.exit(2); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/tools/net-peer.ts b/tools/net-peer.ts new file mode 100644 index 00000000..9406871d --- /dev/null +++ b/tools/net-peer.ts @@ -0,0 +1,111 @@ +// tools/net-peer.ts — the independent HTTP + WebSocket peer for the network +// hardware smoke (hosts/esp-idf/examples/net-smoke). Runs on the workstation +// with Bun; the boards reach it over the LAN. It is deliberately a different +// implementation from the PocketJS core, so the smoke tests the wire against +// an independent peer instead of the same code on both ends. +// +// bun tools/net-peer.ts [--http=8790] [--ws=8791] [--host=0.0.0.0] +// +// Routes: /hello /echo (POST) /json /stream (chunked) /redirect (302 → /hello) +// /big?bytes=N /slow?ms=N /status ; anything else → 404. +// WebSocket: /echo echoes text and binary, negotiates "smoke.v1". + +const args = new Map(process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, "").split("="); + return [k, v ?? "true"] as const; +})); +const httpPort = Number(args.get("http") ?? 8790); +const wsPort = Number(args.get("ws") ?? 8791); +const host = args.get("host") ?? "0.0.0.0"; + +let requests = 0; +const log = (line: string): void => console.log(`[${new Date().toISOString().slice(11, 19)}] ${line}`); + +const http = Bun.serve({ + hostname: host, + port: httpPort, + async fetch(request, server) { + requests++; + const url = new URL(request.url); + const remote = server.requestIP(request); + log(`${remote?.address ?? "?"} ${request.method} ${url.pathname}${url.search}`); + switch (url.pathname) { + case "/hello": + return new Response(`hello from net-peer #${requests}\n`, { headers: { "content-type": "text/plain" } }); + case "/echo": { + const body = new Uint8Array(await request.arrayBuffer()); + return new Response(body, { + headers: { + "content-type": request.headers.get("content-type") ?? "application/octet-stream", + "x-echo-bytes": String(body.byteLength), + }, + }); + } + case "/json": + return Response.json({ peer: "net-peer", requests, now: Date.now() }); + case "/stream": { + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + controller.enqueue(new TextEncoder().encode(`chunk-${i};`)); + await Bun.sleep(30); + } + controller.close(); + }, + }); + return new Response(stream, { headers: { "content-type": "text/plain" } }); + } + case "/redirect": + return new Response(null, { status: 302, headers: { location: "/hello" } }); + case "/big": { + const bytes = Math.min(4 * 1024 * 1024, Math.max(1, Number(url.searchParams.get("bytes") ?? 100000))); + const body = new Uint8Array(bytes); + for (let i = 0; i < bytes; i++) body[i] = 97 + ((i / 1000) | 0) % 26; + return new Response(body, { headers: { "content-type": "application/octet-stream" } }); + } + case "/slow": { + const ms = Math.min(60000, Number(url.searchParams.get("ms") ?? 2000)); + await Bun.sleep(ms); + return new Response("slow\n"); + } + case "/status": + return Response.json({ requests, uptimeMs: Math.round(performance.now()) }); + default: + return new Response("not found\n", { status: 404 }); + } + }, +}); + +const ws = Bun.serve({ + hostname: host, + port: wsPort, + fetch(request, server) { + const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((s) => s.trim()).filter(Boolean); + const selected = protocols.includes("smoke.v1") ? "smoke.v1" : undefined; + const upgraded = server.upgrade(request, { + headers: selected ? { "sec-websocket-protocol": selected } : {}, + data: { remote: server.requestIP(request)?.address ?? "?" }, + }); + if (upgraded) return undefined as unknown as Response; + return new Response("websocket only\n", { status: 426 }); + }, + websocket: { + open(socket) { + log(`ws open from ${(socket.data as { remote: string }).remote}`); + }, + message(socket, message) { + if (typeof message === "string") { + log(`ws text ${JSON.stringify(message).slice(0, 60)}`); + socket.send(message); + } else { + log(`ws binary ${message.byteLength} bytes`); + socket.send(message); + } + }, + close(_socket, code, reason) { + log(`ws close ${code} ${reason}`); + }, + }, +}); + +log(`net-peer http://${host}:${http.port} ws://${host}:${ws.port}`); diff --git a/tools/test.ts b/tools/test.ts index 0121bd9b..d9f8d1bd 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -72,6 +72,7 @@ const SUITE: readonly Stage[] = [ "tests/network-policy.test.ts", "tests/net-policy-hosts.test.ts", "tests/http-semantics.test.ts", + "tests/esp-idf-profile.test.ts", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", "tests/symbian-data.test.ts",