From f6aa072d5f32eb80d2634f6f09e381523b9c17c6 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Fri, 26 Jun 2026 15:12:31 -0500 Subject: [PATCH] fix(el-runtime/win): stop truncating HTTP responses to the last fs_read length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows el runtime stashes the byte count of the last fs_read in a thread-global (_tl_fs_read_len) so binary files (PNG/WOFF2) serve without strlen truncation at embedded NULs. But it was consumed unconditionally: any handler that fs_reads a file and then returns a DIFFERENT, longer response (e.g. /api/safety-contact reads the 178-byte contact file, then wraps it in {"configured":true,"contact":...,"ok":true}) had its reply clipped to the file size — the client received truncated, invalid JSON. Record the buffer the length belongs to (_tl_fs_read_ptr) and only trust the byte count when the handler returns that exact buffer; otherwise measure the built response with strlen. Fixes every fs_read-then-transform endpoint (safety-contact GET/POST, etc.). macOS (native runtime) was unaffected. Verified root cause on Windows 11: GET/POST /api/safety-contact returned Content-Length 178 with a body truncated mid-JSON; macOS returned the full 208-byte body. This is why the Neuron Windows app could not save the safety contact (and would fail most soul calls). Co-Authored-By: Claude Opus 4.8 (1M context) --- lang/el-compiler/runtime/el_runtime.c | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lang/el-compiler/runtime/el_runtime.c b/lang/el-compiler/runtime/el_runtime.c index 4faf72a..9b0a030 100644 --- a/lang/el-compiler/runtime/el_runtime.c +++ b/lang/el-compiler/runtime/el_runtime.c @@ -81,8 +81,13 @@ static _Thread_local ElArena _tl_arena = {NULL, 0, 0}; static _Thread_local int _tl_arena_active = 0; /* Binary-safe fs_read length — set by fs_read, consumed by http_send_response. - * Allows serving PNGs and other binary files without strlen truncation. */ + * Allows serving PNGs and other binary files without strlen truncation. + * _tl_fs_read_ptr records WHICH buffer that length belongs to: the length is only + * valid when the handler returns that exact buffer (a raw file). A handler that + * fs_reads then builds a different/longer response (e.g. wraps a file in JSON) must + * be measured by strlen — otherwise the reply is truncated to the file's size. */ static _Thread_local size_t _tl_fs_read_len = 0; +static _Thread_local const char* _tl_fs_read_ptr = NULL; static void el_arena_track(char* p) { if (!_tl_arena_active || !p) return; @@ -1558,11 +1563,15 @@ static void* http_worker(void* arg) { const char* rs = EL_CSTR(r); /* Copy response out BEFORE arena teardown. * For binary files, _tl_fs_read_len holds the real byte count — - * use memcpy instead of strdup so null bytes are preserved. */ - size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0); + * use memcpy instead of strdup so null bytes are preserved. But only + * trust that count when the handler returned the fs_read buffer itself; + * a wrapped/concatenated response must be measured by strlen. */ + int is_fs_body = (_tl_fs_read_len > 0 && rs == _tl_fs_read_ptr); + size_t rlen = is_fs_body ? _tl_fs_read_len : (rs ? strlen(rs) : 0); response = malloc(rlen + 1); if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; } else if (response) { response[0] = '\0'; } + _tl_fs_read_len = is_fs_body ? rlen : 0; /* length http_send_response should use */ } else { response = el_strdup_persist("el-runtime: no http handler registered"); } @@ -1806,10 +1815,14 @@ static void* http_worker_v2(void* arg) { el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : ""); el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body)); const char* rs = EL_CSTR(r); - size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0); + /* Only trust fs_read's byte count when the handler returned that exact + * buffer (raw binary file); otherwise measure the built response by strlen. */ + int is_fs_body = (_tl_fs_read_len > 0 && rs == _tl_fs_read_ptr); + size_t rlen = is_fs_body ? _tl_fs_read_len : (rs ? strlen(rs) : 0); response = malloc(rlen + 1); if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; } else if (response) { response[0] = '\0'; } + _tl_fs_read_len = is_fs_body ? rlen : 0; /* length http_send_response should use */ el_release(hmap); } else { response = el_strdup_persist( @@ -1926,6 +1939,7 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) { el_val_t fs_read(el_val_t pathv) { const char* path = EL_CSTR(pathv); _tl_fs_read_len = 0; + _tl_fs_read_ptr = NULL; if (!path) return el_wrap_str(el_strdup("")); FILE* f = fopen(path, "rb"); if (!f) return el_wrap_str(el_strdup("")); @@ -1937,6 +1951,7 @@ el_val_t fs_read(el_val_t pathv) { size_t got = fread(buf, 1, (size_t)sz, f); buf[got] = '\0'; _tl_fs_read_len = got; /* store real byte count for binary-safe send */ + _tl_fs_read_ptr = buf; /* ...valid only if THIS buffer is the response body */ fclose(f); return el_wrap_str(buf); }