diff --git a/bindings/pycvc/README.md b/bindings/pycvc/README.md index e6bb80260..c89c38622 100644 --- a/bindings/pycvc/README.md +++ b/bindings/pycvc/README.md @@ -201,7 +201,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 5de4c547d..37a0aba9b 100644 --- a/bindings/pycvc/pycvc.i +++ b/bindings/pycvc/pycvc.i @@ -2650,7 +2650,7 @@ bool values_equal(const value_t &a, const value_t &b); // Parser (docs/STATE_EXEC_GRAMMAR.md). 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 e219f7946..b1ff76252 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/bindings/pycvc/test_pycvc_state_exec.py b/bindings/pycvc/test_pycvc_state_exec.py index 4c2da3dc6..27c11b294 100644 --- a/bindings/pycvc/test_pycvc_state_exec.py +++ b/bindings/pycvc/test_pycvc_state_exec.py @@ -167,7 +167,7 @@ def test_script_reader_reads_in_slices(): assert r.forms() == [[symbol("a"), 1], [symbol("b"), "two"]] assert steps > 3 assert r.source() == '(a 1) (b "two")' - assert r.options().max_depth == 256 + assert r.options().max_depth == pycvc.parse_options().max_depth # the C++ default def test_script_reader_raises_parse_error(): diff --git a/bindings/pycvc/test_pycvc_state_exec_sched.py b/bindings/pycvc/test_pycvc_state_exec_sched.py index c885101b4..9cd836bce 100644 --- a/bindings/pycvc/test_pycvc_state_exec_sched.py +++ b/bindings/pycvc/test_pycvc_state_exec_sched.py @@ -377,7 +377,7 @@ def test_scheduler_settings_come_from_the_tree(): root.child("state_exec.schedulers.py-sched.max_pending_messages").value("2048") root.child("state_exec.schedulers.py-sched.parser.max_depth").value("12") s = pycvc.scheduler() - assert s.parser_settings().max_depth == 256 + assert s.parser_settings().max_depth == pycvc.parse_options().max_depth # the default s.set_watch_root(root) s.set_id("py-sched") assert s.id() == "py-sched" diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index b197ae710..f77f257a4 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,9 @@ 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`, `state-data-get`, `state-json` and - `state-from-json` 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`, `state-json`, + `state-from-json` 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. @@ -253,8 +254,7 @@ A node is an element visited: a list element or a dict entry walked, or an eleme The stdlib's materializers are jobs too: `string.split`/`join`/`replace`/`encode`, `bytes.slice`/`concat`/`decode` and all of `collections.*`. A string function counts a node per -256 bytes it scans or copies (developer guide §11, "Sliced work"). String coercion for -`state-set` still runs whole inside one step, but counts its nodes. +256 bytes it scans or copies (developer guide §11, "Sliced work"). 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. @@ -396,7 +396,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 3c5330b90..4a5780a51 100644 --- a/src/cvc/ariadne/loader.cpp +++ b/src/cvc/ariadne/loader.cpp @@ -1939,26 +1939,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 @@ -1968,7 +1981,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 a453fc49b..5cb7ef6cb 100644 --- a/src/cvc/state/state_exec/intrinsics.cpp +++ b/src/cvc/state/state_exec/intrinsics.cpp @@ -143,9 +143,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 6b73d5074..f50c75a7c 100644 --- a/src/cvc/tests/ariadne_loader_test.cpp +++ b/src/cvc/tests/ariadne_loader_test.cpp @@ -3973,6 +3973,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 2b02c40d1..844c9fd0e 100644 --- a/src/cvc/tests/state_exec_data_steps_test.cpp +++ b/src/cvc/tests/state_exec_data_steps_test.cpp @@ -573,6 +573,40 @@ TEST_F(DataStepsScheduler, BudgetUsageAndInspectReportTheKinds) { 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); +} + // ---- state-json / state-from-json ------------------------------------------------------ // // cvc::state::json() and json(j) as data jobs: the same writer and reader (state_json.h), a diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp index 75478956a..09fc7fdb4 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 @@ -287,6 +287,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;