From 969f11cd6910d36d74370894cf4da0559bbd32a7 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sat, 17 Oct 2026 23:25:59 -0500 Subject: [PATCH] state_exec: parser.max_depth 100000; the last recursive walks over parsed data Nothing in libcvc walks parsed data on the native stack any more, so the parser's nesting limit no longer guards the stack. Its default rises from 256 to 100000, the depth every walker is tested at on a 64 KiB stack (the user's choice). - Ariadne channel lint: collect_channel_refs keeps its open lists on a heap stack. The 256-level cutoff is gone, so a msg-* reference at any depth is checked. - stream-capture-audio: a built tone/gain/mix graph pulls each chunk through one native call per level on the producer thread, and frees itself the same way. A graph nesting mix/gain more than 64 levels deep is refused with an error instead of built. - state-set of a list or dict prints it as a data job, a slice per step when the process slices deep data. The node is written once the whole string is ready. - The test-only tree-walking evaluators' macro substitute runs the stackless evaluator's walk whole, instead of recursing per template level. Tests: ParsedDataAtTheDefaultDepthLimit (parse, compare, print and free a script at the default limit on the small stack), StateSetOfADeepValueSlices, a channel lint test 1000 levels deep, the audio graph cap in cvcgl_media_audio, and the default's asserts. --- bindings/pycvc/README.md | 2 +- bindings/pycvc/pycvc.i | 2 +- bindings/pycvc/test_pycvc_exec.py | 6 +-- docs/STATE_EXEC_GRAMMAR.md | 28 +++++------ inc/cvc/state/state_exec/parser.h | 12 +++-- src/cvc/ariadne/loader.cpp | 49 ++++++++++++------- src/cvc/state/state_exec/async_evaluator.cpp | 19 ++----- src/cvc/state/state_exec/evaluator.cpp | 19 ++----- src/cvc/state/state_exec/intrinsics.cpp | 12 ++++- src/cvc/tests/ariadne_loader_test.cpp | 17 +++++++ src/cvc/tests/state_exec_data_steps_test.cpp | 34 +++++++++++++ src/cvc/tests/state_exec_deep_data_test.cpp | 25 +++++++++- src/cvc/tests/state_exec_grammar_test.cpp | 2 +- src/cvc/tests/state_exec_integration_test.cpp | 6 +-- src/cvc/tests/state_exec_parser_test.cpp | 19 ++++--- src/cvcGL/ariadne/media_verbs.cpp | 25 ++++++++-- src/cvcGL/test/cvcgl_media_audio.cpp | 33 +++++++++++++ 17 files changed, 217 insertions(+), 93 deletions(-) diff --git a/bindings/pycvc/README.md b/bindings/pycvc/README.md index 36e1ffc10..a552161b3 100644 --- a/bindings/pycvc/README.md +++ b/bindings/pycvc/README.md @@ -197,7 +197,7 @@ app = pycvc.make_app() ex = pycvc.Exec(app) ex.run("(+ 1 2)") # "3" -pycvc.parse_options() # max_depth 256, step_tokens 0 +pycvc.parse_options() # max_depth 100000, step_tokens 0 pycvc.async_scheduler.host_parser_defaults() # step_tokens 1024 ex.parser_defaults() # == host_parser_defaults() pycvc.state_set(app, "state_exec.defaults.parser.max_depth", "64") # tree overrides diff --git a/bindings/pycvc/pycvc.i b/bindings/pycvc/pycvc.i index 9d8a1338b..149da149a 100644 --- a/bindings/pycvc/pycvc.i +++ b/bindings/pycvc/pycvc.i @@ -1461,7 +1461,7 @@ class AsyncStateObserver(state_observer): namespace cvc { namespace state_exec { struct parse_options { - size_t max_depth; // lists and quotes that may nest (default 256) + size_t max_depth; // lists and quotes that may nest (default 100000) size_t step_tokens; // 0: parse at submit; N: read N tokens per process step }; struct data_options { diff --git a/bindings/pycvc/test_pycvc_exec.py b/bindings/pycvc/test_pycvc_exec.py index 244fbb6ef..e5a6223ec 100644 --- a/bindings/pycvc/test_pycvc_exec.py +++ b/bindings/pycvc/test_pycvc_exec.py @@ -83,7 +83,7 @@ def test_parser_settings_come_from_the_app_tree(): def test_parse_options_is_the_cpp_struct(): o = pycvc.parse_options() - assert (o.max_depth, o.step_tokens) == (256, 0) # C++ defaults + assert (o.max_depth, o.step_tokens) == (100000, 0) # C++ defaults o.max_depth = 32 o.step_tokens = 8 assert (o.max_depth, o.step_tokens) == (32, 8) @@ -93,7 +93,7 @@ def test_exec_starts_from_the_host_parser_defaults(): # The same defaults as the app's exec scheduler: # async_scheduler::host_parser_defaults(). host = pycvc.async_scheduler.host_parser_defaults() - assert (host.max_depth, host.step_tokens) == (256, 1024) + assert (host.max_depth, host.step_tokens) == (100000, 1024) ex = pycvc.Exec(pycvc.make_app()) d = ex.parser_defaults() assert (d.max_depth, d.step_tokens) == (host.max_depth, host.step_tokens) @@ -115,7 +115,7 @@ def test_parse_options_from_state_resolves_like_cpp(): assert pycvc.parse_options_from_state(root).max_depth == 40 assert pycvc.parse_options_from_state(root, "s1").max_depth == 50 root.child("state_exec.defaults.parser.max_depth").value("nope") # bad: skipped - assert pycvc.parse_options_from_state(root).max_depth == 256 + assert pycvc.parse_options_from_state(root).max_depth == 100000 def test_set_parser_defaults_changes_how_exec_parses(): diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index 00df8b858..fbcd8a3f4 100644 --- a/docs/STATE_EXEC_GRAMMAR.md +++ b/docs/STATE_EXEC_GRAMMAR.md @@ -124,17 +124,17 @@ raw_bytes ::= 'br' raw_body - **Positions.** `parse_error::line()` and `col()` are 1-based, and point at the character where the error was found, such as the non-hex character in `"\x4"`. A new line starts after LF, after CR, and once after CRLF. Columns count code points, not bytes. -- **Nesting.** Lists and quotes may nest at most `max_depth` levels, 256 by default. +- **Nesting.** Lists and quotes may nest at most `max_depth` levels, 100000 by default. - `(a (b (c)))` is three levels, and `'x` counts as one, since it reads as `(quote x)`. - The parser is stackless. Open lists and quotes live on a heap stack, not on the native stack, so no input depth can overflow it. - - The limit is for what happens to the data afterwards. Some code still walks values on the - native stack: destroying a nested list, the snapshot codec, `to_string`. Deeper input is a - `parse_error`, so a script from an untrusted source (a message, an HTTP body, the - multiprocess wire) can't produce data those would crash on. - - The default fits the 64 KiB default stack of a wasm build. Real scripts nest far less: the - deepest in the corpus is 13 levels, and the median is 2. Once that downstream code is - iterative too, the default can rise. + - So is the code that walks the data afterwards: freeing, printing, comparing, copying, macro + expansion, the snapshot codec and the Ariadne channel lint. Each is tested 100000 levels + deep on a 64 KiB stack (the default stack of a wasm build). Values crossing into Python go + through pycvc's converter, which has its own depth limit. + - The default was 256 while some of that code still recursed. It is now a bound on runaway + input, not a guard for the native stack. Real scripts nest far less: the deepest in the + corpus is 13 levels, and the median is 2. - The limit is a parser setting. Like state_exec's other settings, it lives on the state tree; see [Parser settings](#parser-settings). @@ -153,7 +153,7 @@ next one in the order applies. `max_depth` must also be positive. | Key | Meaning | Default | |---|---|---| -| `max_depth` | how many lists and quotes may nest | 256 | +| `max_depth` | how many lists and quotes may nest | 100000 | | `step_tokens` | 0: a scheduler parses a submitted script all at once, when it is submitted. N: it parses the script inside the new process, N tokens per step (see [Reading inside the scheduler](#reading-inside-the-scheduler)) | 0; **1024** for the app's exec scheduler and pycvc's `Exec` | Who reads them: @@ -226,8 +226,8 @@ then `state_exec.defaults.data.step_nodes`, then the scheduler's built-in defaul A node is an element visited: a list element or a dict entry walked, or an element freed. With `step_nodes` = N: -- **Native calls.** `=`, `!=`, `str`, `str-concat`, `print` and `state-data-get` hand their walk - to the evaluator (`run_data_job`). It does one slice inside the call's step; if the walk isn't +- **Native calls.** `=`, `!=`, `str`, `str-concat`, `print`, `state-data-get` and `state-set` of + a list or dict (printed into the node) hand their walk to the evaluator (`run_data_job`). It does one slice inside the call's step; if the walk isn't done, the call's frame waits and the following steps are **data steps** that each run N more nodes. The step that finishes it completes the call. A walk under N nodes finishes in the call's own step, so ordinary programs take no extra steps. @@ -246,8 +246,8 @@ A node is an element visited: a list element or a dict entry walked, or an eleme pointer and reads them by index, checking bounds at every node. A change between two slices (by another process) changes the answer, never memory safety. -Other deep walks run whole inside one step, as before, but count their nodes. These are -`collections.*` helpers and string coercion for `state-set`. +Other deep walks run whole inside one step, as before, but count their nodes. These are the +`collections.*` helpers. Measured per node (MSVC Release, `state_exec_bench_test` `DataWalkThroughput`): ~45 ns to free, ~70 ns to print, ~250 ns to compare and ~380 ns to copy. So a 1024-node slice costs 0.05-0.4 ms. @@ -368,7 +368,7 @@ strings. Every row is a case in `state_exec_grammar_test`. | `[a]`, `a,b`, `` `a ``, `{}`, `a\|b`, `a\b` | odd one-character or glued symbols | error (reserved) | P2 | | `parse("1 2")` | `1`; the rest is ignored | error | P2 | | `'` at end of input | `(quote nil)` | error | P2 | -| 257 nested lists | parsed (no limit) | error at the default `max_depth` | P1b | +| 100001 nested lists | parsed (no limit) | error at the default `max_depth` | P1b | | `; c` CR `1` | comment runs to LF, so no datum | `1` | P2 | | `0x1F` | `31.0`, a double (via `strtod`) | `31` | P2 | | `#x1F`, `0o17`, `0b101`, `1_000` | symbols | integers | P2 | diff --git a/inc/cvc/state/state_exec/parser.h b/inc/cvc/state/state_exec/parser.h index 617adfb33..d990939c7 100644 --- a/inc/cvc/state/state_exec/parser.h +++ b/inc/cvc/state/state_exec/parser.h @@ -42,11 +42,13 @@ class parse_error : public std::runtime_error { /// state tree under `parser.*`; parse_options_from_state() reads them from there. struct parse_options { /// How many lists and quotes may nest (`'x` reads as `(quote x)`, one level); deeper input is - /// a parse_error. The parser is stackless, and so are freeing, printing, comparing, copying, - /// macro-expanding and snapshotting a value; what still walks values recursively (the Ariadne - /// loader's channel lint, which stops at 256 levels, and pycvc's value conversion) is what this - /// keeps parsed data within. State-tree key: `parser.max_depth`. - std::size_t max_depth = 256; + /// a parse_error. The parser is stackless, and so is what walks parsed data in libcvc + /// (freeing, printing, comparing, copying, macro expansion, the snapshot codec, the Ariadne + /// channel lint), each tested 100000 levels deep on a 64 KiB stack. So the default is that + /// depth: a bound on runaway input, not a guard for the native stack. (Values crossing into + /// Python go through pycvc's converter, which has its own depth limit.) + /// State-tree key: `parser.max_depth`. + std::size_t max_depth = 100000; /// How a scheduler parses a submitted script. 0 parses all of it when it is submitted, so /// execute() throws a parse_error. N > 0 parses it inside the new process, N tokens per diff --git a/src/cvc/ariadne/loader.cpp b/src/cvc/ariadne/loader.cpp index fc34430e2..1db002e89 100644 --- a/src/cvc/ariadne/loader.cpp +++ b/src/cvc/ariadne/loader.cpp @@ -1831,26 +1831,39 @@ std::vector parse_customs(Ctx &ctx, const YAML::Node &c) { // references, gated on the document declaring a `channels:` block. Only literal channel names are // visible here; a dynamic `(msg-recv (expr))` name is the runtime policy's job (a follow-up). // Walk a parsed script's value_t tree, collecting the string-literal FIRST arg of every -// (msg-send|msg-recv|msg-pending …) call — the static channel references. Depth-bounded. -void collect_channel_refs(const cvc::state_exec::value_t &v, std::vector &out, - int depth) { +// (msg-send|msg-recv|msg-pending …) call — the static channel references — in source order. +// The walk keeps its open lists on a heap stack, so any depth the parser accepts is linted. +void collect_channel_refs(const cvc::state_exec::value_t &v, std::vector &out) { using namespace cvc::state_exec; - if (depth > 256) - return; - const auto *lp = std::get_if(&v.v); - if (!lp || !*lp) - return; - const std::vector &lst = **lp; - if (!lst.empty()) { - if (const auto *head = std::get_if(&lst[0].v)) { - if ((head->name == "msg-send" || head->name == "msg-recv" || head->name == "msg-pending") && - lst.size() >= 2) - if (const auto *s = std::get_if(&lst[1].v)) - out.push_back(*s); + struct open_list { + const std::vector *elems; + std::size_t next; + }; + std::vector stack; + auto enter = [&](const value_t &e) { + const auto *lp = std::get_if(&e.v); + if (!lp || !*lp) + return; + const std::vector &lst = **lp; + if (!lst.empty()) { + if (const auto *head = std::get_if(&lst[0].v)) { + if ((head->name == "msg-send" || head->name == "msg-recv" || head->name == "msg-pending") && + lst.size() >= 2) + if (const auto *s = std::get_if(&lst[1].v)) + out.push_back(*s); + } + } + stack.push_back({&lst, 0}); + }; + enter(v); + while (!stack.empty()) { + open_list &top = stack.back(); + if (top.next == top.elems->size()) { + stack.pop_back(); + continue; } + enter((*top.elems)[top.next++]); // may push, invalidating top } - for (const value_t &e : lst) - collect_channel_refs(e, out, depth + 1); } // Collect static channel refs from one script string. Only an s-expr (a program, first non-space // '(') can hold a msg-* call; a bare event name or empty script has none. A syntactically-invalid @@ -1860,7 +1873,7 @@ void refs_from_script(const std::string &script, std::vector &out) if (i == std::string::npos || script[i] != '(') return; try { - collect_channel_refs(cvc::state_exec::parse(script), out, 0); + collect_channel_refs(cvc::state_exec::parse(script), out); } catch (...) { } } diff --git a/src/cvc/state/state_exec/async_evaluator.cpp b/src/cvc/state/state_exec/async_evaluator.cpp index 91f863709..2bd480c05 100644 --- a/src/cvc/state/state_exec/async_evaluator.cpp +++ b/src/cvc/state/state_exec/async_evaluator.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -679,22 +680,8 @@ task async_evaluator::apply_closure(const closure_ptr &cls, value_t async_evaluator::substitute(const value_t &tmpl, const std::unordered_map &subst) { - if (auto *sym = std::get_if(&tmpl.v)) { - auto it = subst.find(sym->name); - if (it != subst.end()) - return it->second; - return tmpl; - } - if (auto *lp = std::get_if(&tmpl.v)) { - if (!*lp) - return tmpl; - auto result = std::make_shared>(); - result->reserve((*lp)->size()); - for (auto &e : **lp) - result->push_back(substitute(e, subst)); - return value_t{std::move(result)}; - } - return tmpl; + // The stackless evaluator's walk, run whole: no native recursion per template level. + return run_to_completion(*make_substitute_job(tmpl, subst)); } } // namespace cvc::state_exec diff --git a/src/cvc/state/state_exec/evaluator.cpp b/src/cvc/state/state_exec/evaluator.cpp index a98281ec2..af4d966e5 100644 --- a/src/cvc/state/state_exec/evaluator.cpp +++ b/src/cvc/state/state_exec/evaluator.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -688,22 +689,8 @@ value_t evaluator::apply_closure(const closure_ptr &cls, const std::vector &subst) { - if (auto *sym = std::get_if(&tmpl.v)) { - auto it = subst.find(sym->name); - if (it != subst.end()) - return it->second; - return tmpl; - } - if (auto *lp = std::get_if(&tmpl.v)) { - if (!*lp) - return tmpl; - auto result = std::make_shared>(); - result->reserve((*lp)->size()); - for (auto &e : **lp) - result->push_back(substitute(e, subst)); - return value_t{std::move(result)}; - } - return tmpl; + // The stackless evaluator's walk, run whole: no native recursion per template level. + return run_to_completion(*make_substitute_job(tmpl, subst)); } } // namespace cvc::state_exec diff --git a/src/cvc/state/state_exec/intrinsics.cpp b/src/cvc/state/state_exec/intrinsics.cpp index de35c0652..cf5fde583 100644 --- a/src/cvc/state/state_exec/intrinsics.cpp +++ b/src/cvc/state/state_exec/intrinsics.cpp @@ -141,9 +141,17 @@ value_t intrinsic_state_set(intrinsics_context *ctx, std::span ar // Coerce the value to a string (state stores string-typed scalars) rather than // requiring the caller to pre-stringify — (state-set "n" 10) now stores "10". A // structured value should use state-data-set (the typed data() channel). - const std::string val = coerce_state_string(args[1]); + if (is_compound(args[1])) { + // A list or dict prints as a walk, a slice per step when the process slices deep data + // ("Deep data on the executor"); the node is written once the whole string is ready. + cvc::state *root = ctx->root; + return run_data_job(make_print_job(args[1]), [root, path = path](value_t printed) { + (*root)(path).value(std::get(printed.v)); + return nil_value; + }); + } // operator() creates child nodes as needed - (*ctx->root)(path).value(val); + (*ctx->root)(path).value(coerce_state_string(args[1])); return nil_value; } diff --git a/src/cvc/tests/ariadne_loader_test.cpp b/src/cvc/tests/ariadne_loader_test.cpp index b7bdc92ec..032f4ed45 100644 --- a/src/cvc/tests/ariadne_loader_test.cpp +++ b/src/cvc/tests/ariadne_loader_test.cpp @@ -3856,6 +3856,23 @@ TEST(AriadneChannelLint, StrictUndeclaredChannelFailsLoad) { EXPECT_NE(r.error.find("other.chan"), std::string::npos); } +TEST(AriadneChannelLint, SeesChannelsNestedAnyDepth) { + // The lint walks a script on a heap stack, so a reference 1000 levels down is still checked + // (it used to stop at 256). + SKIP_WITHOUT_YAML(); + const int n = 1000; + std::string on; + for (int i = 0; i < n; ++i) + on += "(begin "; + on += "(msg-send \"deep.chan\" \"x\")"; + on += std::string(n, ')'); + LoadResult r = load_string("channels:\n - nav.done\nwindows:\n - window: W\n children:\n" + " - button: Go\n on: '" + + on + "'\n"); + EXPECT_FALSE(r.ok); + EXPECT_NE(r.error.find("deep.chan"), std::string::npos) << r.error; +} + TEST(AriadneChannelLint, DeclaredChannelPasses) { SKIP_WITHOUT_YAML(); LoadResult r = load_string(R"( diff --git a/src/cvc/tests/state_exec_data_steps_test.cpp b/src/cvc/tests/state_exec_data_steps_test.cpp index 883dbc2b6..04843941b 100644 --- a/src/cvc/tests/state_exec_data_steps_test.cpp +++ b/src/cvc/tests/state_exec_data_steps_test.cpp @@ -444,3 +444,37 @@ TEST_F(DataStepsScheduler, BudgetUsageAndInspectReportTheKinds) { ASSERT_TRUE(info); EXPECT_GT(info->data_time, 0.0); } + +TEST_F(DataStepsScheduler, StateSetOfADeepValueSlices) { + async_scheduler sched; + sched.set_watch_root(&root); + root("state_exec.defaults.data.step_nodes").value("16"); + memory_tracker tracker; + intrinsics_context ictx; + ictx.sched = &sched; + ictx.root = &root; + ictx.tracker = &tracker; + auto env = builtins::make_default_environment(); + register_intrinsics(env, &ictx); + execute_options opts; + opts.env = env; + const int pid = + sched.execute(std::string("(begin (set a 1) (set i 0)" + " (while (< i 300) (begin (set a (list a)) (set i (+ i 1))))" + " (state-set \"deep.text\" a) (state-set \"deep.n\" 7)" + " (state-get \"deep.text\"))"), + opts); + // The list prints a slice per step, and the node is written once the whole string is ready. + while (sched.get_process_info(pid)->data_steps == 0) + sched.sync_step(); + EXPECT_EQ(root.findDescendant("deep.text"), nullptr); + sched.sync_run(); + auto info = sched.get_process_info(pid); + ASSERT_TRUE(info); + EXPECT_EQ(info->status, process_status::terminated); + EXPECT_EQ(root("deep.text").value(), to_string(nested_list(300))); + EXPECT_EQ(root("deep.n").value(), "7"); + EXPECT_EQ(std::get(sched.get_result(pid)->v), root("deep.text").value()); + EXPECT_GE(info->data_steps, 300u / 16 - 1); + EXPECT_GE(info->data_nodes, 300u); +} diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp index 98a46d6af..d9a617468 100644 --- a/src/cvc/tests/state_exec_deep_data_test.cpp +++ b/src/cvc/tests/state_exec_deep_data_test.cpp @@ -1,7 +1,7 @@ // Deeply nested state_exec values must not overflow the native stack. // -// The parser's nesting limit (parser.max_depth on the state tree) exists because the reader -// recurses; it can rise only once nothing else does either. These tests build values 100000 +// The parser's nesting limit (parser.max_depth on the state tree) defaults to the depth these +// tests prove: nothing that walks a value recurses once per level. These tests build values 100000 // levels deep at runtime (not through the parser) and drop, print, compare and snapshot them // (the deep copy behind state-data-get is in state_exec_intrinsics_test). Each body runs on a // thread with a small fixed stack (kStackKiB), so a walker that recurses once per level fails @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -285,6 +286,26 @@ TEST(StateExecDeepData, MacroExpandsDeepTemplate) { }); } +// ---- parsed data ------------------------------------------------------------- + +TEST(StateExecDeepData, ParsedDataAtTheDefaultDepthLimit) { + // A script nesting its data as deeply as parser.max_depth allows by default parses, evaluates, + // compares, prints and frees on the small stack. (= 'X 'X) quotes X twice, so X nests one + // level less than the limit. + on_small_stack([] { + const std::size_t n = parse_options{}.max_depth - 2; + const std::string x = std::string(n, '(') + "1" + std::string(n, ')'); + stackless_evaluator ev(builtins::make_default_environment()); + value_t same = ev.evaluate(parse("(= '" + x + " '" + x + ")")); + EXPECT_TRUE(std::get(same.v)); + value_t printed = ev.evaluate(parse("(str '" + x + ")")); + const std::string &s = std::get(printed.v); + EXPECT_EQ(s.rfind("(((", 0), 0u); + EXPECT_LE(s.size(), std::size_t{2} * 65536 + 16); // to_string's length cap + EXPECT_THROW(parse(std::string(n + 3, '(') + std::string(n + 3, ')')), parse_error); + }); +} + // ---- snapshot codec -------------------------------------------------------- class StateExecDeepDataCodec : public ::testing::Test { diff --git a/src/cvc/tests/state_exec_grammar_test.cpp b/src/cvc/tests/state_exec_grammar_test.cpp index 7653b6ab4..a77ca1f28 100644 --- a/src/cvc/tests/state_exec_grammar_test.cpp +++ b/src/cvc/tests/state_exec_grammar_test.cpp @@ -96,7 +96,7 @@ const std::vector &cases() { {"P2", "reserved", mode::all, "a\\b", "!error"}, {"P2", "single_datum", mode::one, "1 2", "!error"}, {"P2", "quoted", mode::all, "'", "!error"}, - {"P1b", "list", mode::all, nest(257), "!error"}, + {"P1b", "list", mode::all, nest(100001), "!error"}, {"P2", "line_end line_comment", mode::all, "; c\r1", "1"}, {"P2", "radix_integer hex_digits", mode::all, "0x1F 0X1f #x1F #X1f 0xFF_FF", "31\n31\n31\n31\n65535"}, diff --git a/src/cvc/tests/state_exec_integration_test.cpp b/src/cvc/tests/state_exec_integration_test.cpp index 561f38ca7..3b2f83bbb 100644 --- a/src/cvc/tests/state_exec_integration_test.cpp +++ b/src/cvc/tests/state_exec_integration_test.cpp @@ -2338,7 +2338,7 @@ TEST_F(SchedulerSettingsTest, TwoSchedulersWithDifferentOverrides) { TEST_F(SchedulerSettingsTest, ParserSettingsResolveFromTree) { auto &root = cvc::state::instance(app_ctx); - EXPECT_EQ(parse_options_from_state(root).max_depth, 256u); // nothing set: built-in default + EXPECT_EQ(parse_options_from_state(root).max_depth, 100000u); // nothing set: built-in default root("state_exec.defaults.parser.max_depth").value("64"); EXPECT_EQ(parse_options_from_state(root).max_depth, 64u); @@ -2354,7 +2354,7 @@ TEST_F(SchedulerSettingsTest, BadParserSettingsAreSkipped) { auto &root = cvc::state::instance(app_ctx); for (const char *bad : {"", "abc", "0", "-5", "12x", "99999999999999999999999"}) { root("state_exec.defaults.parser.max_depth").value(bad); - EXPECT_EQ(parse_options_from_state(root).max_depth, 256u) << "value: " << bad; + EXPECT_EQ(parse_options_from_state(root).max_depth, 100000u) << "value: " << bad; } // A bad per-scheduler value falls through to the defaults key, not to the built-in default. root("state_exec.defaults.parser.max_depth").value("32"); @@ -2370,7 +2370,7 @@ TEST_F(SchedulerSettingsTest, SchedulerParsesWithTreeDepthAndPublishesIt) { scheduler sched; sched.set_watch_root(&root); sched.set_id("strict"); - EXPECT_EQ(sched.parser_settings().max_depth, 256u); // until load_settings() + EXPECT_EQ(sched.parser_settings().max_depth, 100000u); // until load_settings() sched.load_settings(); EXPECT_EQ(sched.parser_settings().max_depth, 3u); EXPECT_EQ(root("state_exec.schedulers.strict.parser.max_depth").value(), "3"); diff --git a/src/cvc/tests/state_exec_parser_test.cpp b/src/cvc/tests/state_exec_parser_test.cpp index 888bc8b86..14fbae60e 100644 --- a/src/cvc/tests/state_exec_parser_test.cpp +++ b/src/cvc/tests/state_exec_parser_test.cpp @@ -245,10 +245,10 @@ static std::string nested(std::size_t depth) { return std::string(depth, '(') + std::string(depth, ')'); } -TEST(StateExecParserTest, NestingLimitDefaultsTo256) { - EXPECT_EQ(parse_options{}.max_depth, 256u); - EXPECT_NO_THROW(parse(nested(256))); - EXPECT_THROW(parse(nested(257)), parse_error); +TEST(StateExecParserTest, NestingLimitDefaultsTo100000) { + EXPECT_EQ(parse_options{}.max_depth, 100000u); + EXPECT_NO_THROW(parse(nested(100000))); + EXPECT_THROW(parse(nested(100001)), parse_error); } TEST(StateExecParserTest, NestingLimitIsConfigurable) { @@ -265,8 +265,15 @@ TEST(StateExecParserTest, NestingLimitIsConfigurable) { } TEST(StateExecParserTest, NestingLimitStopsRunawayInputBeforeTheStackDoes) { - // A hundred thousand open parens would recurse that deep; the limit stops it at 257. - EXPECT_THROW(parse(std::string(100000, '(')), parse_error); + // Runaway input: the limit stops a million open parens at the 100001st. + try { + parse(std::string(1000000, '(')); + FAIL() << "expected a parse_error"; + } catch (const parse_error &e) { + EXPECT_NE(std::string(e.what()).find("nesting deeper than 100000"), std::string::npos) + << e.what(); + EXPECT_EQ(e.col(), 100001u); + } } TEST(StateExecParserTest, NestingErrorPointsAtTheParenThatGoesTooDeep) { diff --git a/src/cvcGL/ariadne/media_verbs.cpp b/src/cvcGL/ariadne/media_verbs.cpp index e36c4d3f9..91d8b194b 100644 --- a/src/cvcGL/ariadne/media_verbs.cpp +++ b/src/cvcGL/ariadne/media_verbs.cpp @@ -105,23 +105,34 @@ bool as_bool(const se::value_t *v, bool def) { // rate/ch/frames come from the caller (matched to the stream's open); every source here produces // frames*ch*4 bytes. A device microphone is NOT built here — it is SDL/cvcGL and is handled at the // top level of stream-capture-audio. +// +// A built graph pulls each chunk through one native call per mix/gain level on the producer +// thread (fill() calls its inputs' fill()), and frees itself the same way, so at most +// kMaxAudioGraphDepth mix/gain levels may nest. A deeper SPEC builds nothing and sets `too_deep`. +// `depth` is how many mix/gain levels are above `spec`. +constexpr int kMaxAudioGraphDepth = 64; std::unique_ptr build_audio_source(const se::value_t &spec, int rate, int ch, - int frames) { + int frames, bool &too_deep, int depth = 0) { + if (depth > kMaxAudioGraphDepth) { + too_deep = true; + return nullptr; + } if (const se::value_t *m = dict_get(spec, "mix")) { std::vector> ins; if (const se::list_ptr *lp = std::get_if(&m->v)) if (*lp) for (const se::value_t &e : **lp) - if (std::unique_ptr in = build_audio_source(e, rate, ch, frames)) + if (std::unique_ptr in = + build_audio_source(e, rate, ch, frames, too_deep, depth + 1)) ins.push_back(std::move(in)); - if (ins.empty()) + if (ins.empty() || too_deep) return nullptr; return std::make_unique(std::move(ins), rate, ch, frames); } if (const se::value_t *g = dict_get(spec, "gain")) { const se::value_t *in = dict_get(*g, "in"); std::unique_ptr inner = - in ? build_audio_source(*in, rate, ch, frames) : nullptr; + in ? build_audio_source(*in, rate, ch, frames, too_deep, depth + 1) : nullptr; if (!inner) return nullptr; return std::make_unique(std::move(inner), ch, frames, @@ -279,8 +290,12 @@ void register_media_intrinsics() { "pass a tone/gain/mix graph for a hardware-free source"); #endif } else { + bool too_deep = false; source = build_audio_source(spec ? *spec : se::value_t(), static_cast(rate), - static_cast(chn), static_cast(frames)); + static_cast(chn), static_cast(frames), too_deep); + if (too_deep) + return err("stream-capture-audio: the audio graph nests mix/gain more than " + + std::to_string(kMaxAudioGraphDepth) + " levels deep"); } if (!source) return err("stream-capture-audio: empty or invalid audio graph"); diff --git a/src/cvcGL/test/cvcgl_media_audio.cpp b/src/cvcGL/test/cvcgl_media_audio.cpp index 4915026d9..c0ca23304 100644 --- a/src/cvcGL/test/cvcgl_media_audio.cpp +++ b/src/cvcGL/test/cvcgl_media_audio.cpp @@ -91,6 +91,39 @@ int main() { const long long published = static_cast(ch->total_published()); sub.reset(); // drop before teardown; the Runtime then closes the stream (joins the producer) + + // A graph nesting mix/gain more than 64 levels deep is refused, not built: a built graph pulls + // each chunk through one native call per level on the producer thread. 64 levels still play. + rt.set_tick_program( + "(if (= (state-get \"deep.done\") \"1\") #f" + " (begin" + " (set g (dict \"tone\" (dict \"hz\" 440)))" + " (set i 0)" + " (while (< i 63)" + " (begin (set g (dict \"gain\" (dict \"gain\" 1 \"in\" g))) (set i (+ i 1))))" + " (set ok (stream-capture-audio" + " (get-attr (stream-open (dict \"id\" \"deep64\" \"codec\" \"f32\"" + " \"rate\" 48000 \"ch\" 1 \"frames_per_chunk\" 256))" + " \"token\")" + " (dict \"rate\" 48000 \"ch\" 1 \"frames\" 256 \"gain\" (dict \"gain\" 1 \"in\" g))))" + " (set g (dict \"gain\" (dict \"gain\" 1 \"in\" g)))" + " (set no (stream-capture-audio" + " (get-attr (stream-open (dict \"id\" \"deep65\" \"codec\" \"f32\"" + " \"rate\" 48000 \"ch\" 1 \"frames_per_chunk\" 256))" + " \"token\")" + " (dict \"rate\" 48000 \"ch\" 1 \"frames\" 256 \"gain\" (dict \"gain\" 1 \"in\" g))))" + " (state-set \"deep.ok\" (get-attr ok \"ok\"))" + " (state-set \"deep.error\" (get-attr no \"error\"))" + " (state-set \"deep.done\" \"1\")))"); + rt.drain(); + for (const std::string &w : rt.take_reactive_warnings()) + std::printf("warn: %s\n", w.c_str()); + cvc::state &root = cvc::state::instance(app); + assert(root("deep.done").value() == "1" && "the deep-graph program ran"); + assert(root("deep.ok").value() == "true" && "a 64-level graph is built"); + assert(root("deep.error").value().find("more than 64 levels deep") != std::string::npos && + "a 65-level graph is refused with the depth cap's error"); + std::printf("cvcgl_media_audio: OK (mix(tone,tone) via DSL -> %lld chunks of %zu-byte f32)\n", published, f->size); return 0;