From e64bfcd30e9d34e18d6de0ed77d9d10d130f05ba Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 6 Oct 2026 07:08:28 +0000 Subject: [PATCH] fix: release caught runtime diagnostic values --- CHANGELOG.md | 5 +++ docs/CURRENT_STATUS.md | 2 +- docs/HARDENING.md | 6 ++- src/claro.c | 4 +- tools/validate_runtime_error_cleanup.py | 50 +++++++++++++++++++++++++ tools/validate_v1_17.py | 1 + 6 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tools/validate_runtime_error_cleanup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa8cb9..ea92a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +### Release caught runtime diagnostic values + +- Runtime errors caught by `TRY` now release their prior owned message and file strings when the error state is cleared. +- Added `tools/validate_runtime_error_cleanup.py` to exercise builtin-arity and inverted-range diagnostics under ASan/UBSan/LSan. + ### Diagnose missing values in untyped object-field assignments - `claro typecheck` now explains how to repair `SET player.score` by adding a value expression after the field name. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 02afcf8..9b5552a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -28,7 +28,7 @@ Verified in this checkout on 2026-09-23: - `./claro doctor`: all checks `OK`. - `./claro validate`: validation passed. -The memory cleanup slices release the previous deep value when a runtime variable or map entry is overwritten, release temporary split argument arrays and strings from `DO`, `CALL`, `TEXT ... CONTAINS`, and `RANDOM`, release loaded program paths, lines, and pointer arrays at the end of each script run, and now release the remaining runtime-owned variables, functions, modules, classes, import paths, captured output, return value, and error strings before the interpreter exits. The expression evaluator now releases discarded intermediate `Value` operands and command boundaries release evaluated `SET`/`SAY` values after copying or printing them. `FOR EACH` now releases its copied collection after iteration, preventing discarded list/map copies from accumulating in long scripts. `IF` and `DO ... TIMES` now release their temporary control-expression values after branch/loop selection. `ASK` now releases evaluated prompt values and temporary input values after converting/copying them into runtime storage. Focused coverage is `tools/validate_memory_cleanup.py`, `tools/validate_expression_cleanup.py`, `tools/validate_control_flow_cleanup.py`, `tools/validate_control_expression_cleanup.py`, and `tools/validate_ask_prompt_cleanup.py`; each focused cleanup validator runs an ASan/UBSan build with LeakSanitizer checking. Verified on 2026-09-24: `python3 tools/validate_ask_prompt_cleanup.py` passes (2,000 prompt/input operations under ASan/UBSan/LSan); `make -s all`, `./claro test`, `./claro doctor`, and `./claro validate` all pass. A malformed-input ASan/UBSan/LSan smoke run did not pass: it reports two leaked `rt_error` message strings (140 bytes total) at `src/claro.c:142`. This unrelated existing diagnostic-path leak remains unaddressed and blocks claiming sanitizer-clean malformed-input handling. The `COUNT ... AS` command now releases its evaluated list/map value after extracting the item count. `python3 tools/validate_count_expression_cleanup.py` first reproduced a 28,000-byte leak across 2,000 list expressions, then passed with LeakSanitizer enabled after the fix. `GET ... AT ... AS ...` now releases its copied collection and empty-result temporary after storing the selected item; `python3 tools/validate_get_expression_cleanup.py` first reproduced 34,000 bytes of leaks across 2,000 empty-list lookups, then passed with LeakSanitizer enabled after the fix. Remaining memory-growth areas include other expression temporaries and command boundaries. The `RUN COMMAND` path remains trusted shell execution, not a sandbox. +The memory cleanup slices release the previous deep value when a runtime variable or map entry is overwritten, release temporary split argument arrays and strings from `DO`, `CALL`, `TEXT ... CONTAINS`, and `RANDOM`, release loaded program paths, lines, and pointer arrays at the end of each script run, and now release the remaining runtime-owned variables, functions, modules, classes, import paths, captured output, return value, and error strings before the interpreter exits. The expression evaluator now releases discarded intermediate `Value` operands and command boundaries release evaluated `SET`/`SAY` values after copying or printing them. `FOR EACH` now releases its copied collection after iteration, preventing discarded list/map copies from accumulating in long scripts. `IF` and `DO ... TIMES` now release their temporary control-expression values after branch/loop selection. `ASK` now releases evaluated prompt values and temporary input values after converting/copying them into runtime storage. Focused coverage is `tools/validate_memory_cleanup.py`, `tools/validate_expression_cleanup.py`, `tools/validate_control_flow_cleanup.py`, `tools/validate_control_expression_cleanup.py`, and `tools/validate_ask_prompt_cleanup.py`; each focused cleanup validator runs an ASan/UBSan build with LeakSanitizer checking. Verified on 2026-09-24: `python3 tools/validate_ask_prompt_cleanup.py` passes (2,000 prompt/input operations under ASan/UBSan/LSan); `make -s all`, `./claro test`, `./claro doctor`, and `./claro validate` all pass. Caught `TRY` errors now release their previous message and source-path values when cleared; `python3 tools/validate_runtime_error_cleanup.py` confirms caught builtin-arity and inverted-range errors pass ASan/UBSan/LSan. Additional malformed-input leak sites may remain, so this does not claim all runtime allocations are leak-free. The `COUNT ... AS` command now releases its evaluated list/map value after extracting the item count. `python3 tools/validate_count_expression_cleanup.py` first reproduced a 28,000-... [truncated] The `FIND ... IN ... AS ...` command now releases its evaluated search value and copied list/map after storing the result. Focused coverage is `tools/validate_find_expression_cleanup.py`; it runs 2,000 searches under ASan/UBSan with LeakSanitizer enabled. Verified on 2026-09-24: `python3 tools/validate_find_expression_cleanup.py` passes with no reported leaks; `make -s all`, `./claro test` (0 failures), `./claro doctor`, `./claro validate`, and `git diff --check` pass. This slice is runtime-verified under sanitizers; broader malformed-input sanitizer coverage remains blocked by the previously documented `rt_error` message leak. diff --git a/docs/HARDENING.md b/docs/HARDENING.md index aadbc5b..107248f 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -35,7 +35,11 @@ The `REMOVE` command now releases its evaluated needle and copied list/map value When `REMOVE` finds an item in its copied list, it now frees that removed item before shifting the remaining owned values left. `python3 tools/validate_remove_list_item_cleanup.py` repeatedly adds and removes strings under ASan/UBSan/LSan to check that the item ownership is released without changing the list update behavior. -The `PUT ... KEY ... VALUE ...` command now releases its evaluated map, key, and value copies after `map_put`/`rt_set` have copied the data they own. Focused verification: `python3 tools/validate_put_expression_cleanup.py` exercises 2,000 string-valued writes under ASan/UBSan/LSan and passes with no reported leaks. This covers expression-temporary ownership only; broader malformed-input sanitizer runs still report the previously documented `rt_error` diagnostic-string leaks. +The `PUT ... KEY ... VALUE ...` command now releases its evaluated map, key, and value copies after `map_put`/`rt_set` have copied the data they own. Focused verification: `python3 tools/validate_put_expression_cleanup.py` exercises 2,000 string-valued writes under ASan/UBSan/LSan and passes with no reported leaks. This covers expression-temporary ownership only; caught runtime diagnostic-value leaks are covered by the separate cleanup slice below. + +Caught runtime errors release the previous owned error-message and source-path strings when `TRY` clears the error state. `python3 tools/validate_runtime_error_cleanup.py` covers repeated caught builtin-arity errors and an inverted `random.int` range under ASan/UBSan/LSan. + +Verified 2026-10-06: `python3 tools/validate_runtime_error_cleanup.py` passes with LeakSanitizer enabled; `make -s all`, `./claro test` (0 failures), `./claro doctor`, and `./claro validate` pass. The focused validator is also part of `tools/validate_v1_17.py`. This addresses caught diagnostic-value ownership only; it does not claim every malformed-input path is leak-free. ## HTTP response handling diff --git a/src/claro.c b/src/claro.c index 8cd5c80..fd06b7e 100644 --- a/src/claro.c +++ b/src/claro.c @@ -139,8 +139,8 @@ static int rt_set_checked(Runtime *rt,const char *file,int line,const char *name rt_set(rt,name,val); return 1; } static void graphics_note(Runtime *rt,const char *what){ (void)what; rt_set(rt,"GRAPHICS_READY",v_bool(1)); } -static void rt_error(Runtime *rt,const char *file,int line,const char *fmt,...){ va_list ap; char buf[1024]; char *nfile; if(rt->error) return; va_start(ap,fmt); vsnprintf(buf,sizeof(buf),fmt,ap); va_end(ap); nfile=normalize_path_copy(file?file:""); rt->error=1; rt->err_msg=xstrdup(buf); rt->err_file=xstrdup(nfile); rt->err_line=line; rt_set(rt,"LASTERROR",v_str(buf)); rt_set(rt,"LASTERRORFILE",v_str(nfile)); rt_set(rt,"LASTERRORLINE",v_num(line)); free(nfile); } -static void rt_clear_error(Runtime *rt){ rt->error=0; } +static void rt_error(Runtime *rt,const char *file,int line,const char *fmt,...){ va_list ap; char buf[1024]; char *nfile; Value message,file_value; if(rt->error) return; va_start(ap,fmt); vsnprintf(buf,sizeof(buf),fmt,ap); va_end(ap); nfile=normalize_path_copy(file?file:""); rt->error=1; rt->err_msg=xstrdup(buf); rt->err_file=xstrdup(nfile); rt->err_line=line; message=v_str(buf); rt_set(rt,"LASTERROR",message); value_free(message); file_value=v_str(nfile); rt_set(rt,"LASTERRORFILE",file_value); value_free(file_value); rt_set(rt,"LASTERRORLINE",v_num(line)); free(nfile); } +static void rt_clear_error(Runtime *rt){ rt->error=0; free(rt->err_msg); free(rt->err_file); rt->err_msg=NULL; rt->err_file=NULL; } static void out_line(Runtime *rt,Value v){ char *s=v_to_string(v); if(rt->capture){ str_add(&rt->captured,s); str_ch(&rt->captured,'\n'); } else { printf("%s\n",s); } free(s); } static void add_function(Runtime *rt,const char *name,char **params,int pcnt,Program *p,int start,int end){ Function *f=(Function*)xmalloc(sizeof(Function)); int i; f->name=xstrdup(name); f->pcnt=pcnt; f->params=(char**)xmalloc(sizeof(char*)*(pcnt?pcnt:1)); for(i=0;iparams[i]=xstrdup(params[i]); f->prog=p; f->start=start; f->end=end; f->next=rt->funcs; rt->funcs=f; } static Function *find_function(Runtime *rt,const char *name){ Function *f=rt->funcs; while(f){ if(strcmp(f->name,name)==0) return f; f=f->next;} return NULL; } diff --git a/tools/validate_runtime_error_cleanup.py b/tools/validate_runtime_error_cleanup.py new file mode 100644 index 0000000..5fd5571 --- /dev/null +++ b/tools/validate_runtime_error_cleanup.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Check that caught runtime diagnostics release their owned message values.""" +from pathlib import Path +import os +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parent.parent +CASES = { + "tests/38_builtin_arity.claro": "math.abs needs 1 argument", + "tests/39_random_inverted_range.claro": "random.int needs the lower bound", +} + + +def main() -> int: + if os.name == "nt": + print("SKIP: LeakSanitizer check is POSIX-only") + return 0 + with tempfile.TemporaryDirectory(prefix="runtime-error-cleanup-") as tmp: + binary = Path(tmp) / "claro-asan" + build = subprocess.run( + ["gcc", "-std=c99", "-O0", "-g", "-fsanitize=address,undefined", + "src/claro.c", "-o", str(binary), "-lm"], + cwd=ROOT, text=True, capture_output=True, + ) + if build.returncode: + print(build.stdout, end="") + print(build.stderr, end="", file=sys.stderr) + return build.returncode + for script, expected in CASES.items(): + run = subprocess.run( + [str(binary), script], cwd=ROOT, text=True, capture_output=True, + env={**os.environ, "ASAN_OPTIONS": "detect_leaks=1"}, + ) + if run.returncode or "LeakSanitizer" in run.stderr or "SUMMARY:" in run.stderr: + print(f"FAIL: caught diagnostics in {script} retain allocations") + print(run.stdout, end="") + print(run.stderr, end="", file=sys.stderr) + return 1 + if expected not in run.stdout: + print(f"FAIL: expected diagnostic was not printed for {script}") + print(run.stdout, end="") + return 1 + print("PASS: caught builtin-arity and inverted-range diagnostics are leak-free") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_v1_17.py b/tools/validate_v1_17.py index b0024e2..c702b4e 100644 --- a/tools/validate_v1_17.py +++ b/tools/validate_v1_17.py @@ -47,6 +47,7 @@ def main(): run([sys.executable, "tools/validate_concurrency.py"]) run([sys.executable, "tools/validate_package_security.py"]) run([sys.executable, "tools/validate_typecheck_diagnostics.py"]) + run([sys.executable, "tools/validate_runtime_error_cleanup.py"]) run([sys.executable, "tools/validate_version_convention.py"]) run([str(EXE), "examples/networking.claro"]) print("v1.18.26 validation complete")