diff --git a/inc/cvc/state/state_exec/types.h b/inc/cvc/state/state_exec/types.h index 9a6b34d53..0ce3556cb 100644 --- a/inc/cvc/state/state_exec/types.h +++ b/inc/cvc/state/state_exec/types.h @@ -71,6 +71,24 @@ struct bytes_value { bool operator==(const bytes_value &o) const { return data == o.data; } }; +namespace detail { +/// Drop one owner of a nested value's storage (a list, dict, closure, generator or +/// environment) without recursing once per nesting level. Freeing a 100000-deep list through +/// plain shared_ptr destructors takes a native stack frame per level and overflows the stack. +/// Here the first release on a thread runs a loop: owners dropped while it frees something +/// (that thing's elements, bindings, enclosing scope) are queued and freed one at a time by the +/// loop, so the stack stays a few frames deep whatever the depth. +void release_nested(std::shared_ptr owner) noexcept; + +/// Drop `owner` through release_nested when it is the last one; dropping any other owner only +/// decrements a count. (Should another thread drop the last but one meanwhile, this drop frees +/// the storage inline, and its elements start releases of their own: a few frames, still.) +template void drop_owner(std::shared_ptr &owner) noexcept { + if (owner && owner.use_count() == 1) + release_nested(std::move(owner)); +} +} // namespace detail + /// The DSL value variant. All runtime values are represented as value_t. /// /// Variants: @@ -114,6 +132,23 @@ struct value_tag { value_tag(generator_ptr g) : v(std::move(g)) {} value_tag(bytes_value b) : v(std::move(b)) {} + value_tag(const value_tag &) = default; + value_tag(value_tag &&) = default; + value_tag &operator=(const value_tag &) = default; + value_tag &operator=(value_tag &&) = default; + + /// Nested storage is freed through detail::drop_owner, never recursively. + ~value_tag() { + if (auto *l = std::get_if(&v)) + detail::drop_owner(*l); + else if (auto *d = std::get_if(&v)) + detail::drop_owner(*d); + else if (auto *c = std::get_if(&v)) + detail::drop_owner(*c); + else if (auto *g = std::get_if(&v)) + detail::drop_owner(*g); + } + /// Check if the value is nil (monostate). bool is_nil() const { return std::holds_alternative(v); } @@ -145,6 +180,9 @@ struct environment { std::unordered_map bindings; std::shared_ptr outer; + /// Drops `outer` through detail::drop_owner, so a long scope chain is not freed recursively. + ~environment(); + /// Look up a name, walking the scope chain. value_t *lookup(const std::string &name); const value_t *lookup(const std::string &name) const; @@ -189,13 +227,15 @@ inline const value_t true_value{true}; inline const value_t false_value{false}; /// Convert a value_t to a human-readable string representation. Bounded and cycle-safe: -/// output is length- and depth-capped (a shared/deep/cyclic structure is truncated with an -/// ellipsis rather than expanding exponentially or overflowing the stack). +/// output is length-capped (a shared/cyclic structure is truncated with an ellipsis rather +/// than expanding exponentially), and the walk is iterative, so depth alone neither truncates +/// it nor overflows the stack. std::string to_string(const value_t &val); /// Check structural equality of two values. Memoized on pointer-pairs, so a shared or /// cyclic structure is compared in time proportional to its PHYSICAL size (distinct node -/// pairs), never its exponential logical unfolding. +/// pairs), never its exponential logical unfolding. Iterative, so any depth compares; it polls +/// the evaluation deadline and throws std::runtime_error once that is spent. bool values_equal(const value_t &a, const value_t &b); // -- time budgets ------------------------------------------------------------- diff --git a/src/cvc/state/state_exec/intrinsics.cpp b/src/cvc/state/state_exec/intrinsics.cpp index 1de778b3b..43ca8ce6c 100644 --- a/src/cvc/state/state_exec/intrinsics.cpp +++ b/src/cvc/state/state_exec/intrinsics.cpp @@ -92,51 +92,51 @@ std::string coerce_state_string(const value_t &v) { // not alias node-resident storage. Bounded and cycle-safe, mirroring values_equal / to_string // (it is the third structural walker and must not be the weak link): it MEMOIZES on the source // node pointer, so a shared-pointer DAG is copied in PHYSICAL time (the copy preserves the same -// sharing, disjoint from the stored original) and a cycle terminates; it caps recursion depth -// (stack safety on a deeply-nested value); and it polls the evaluation deadline so a huge -// payload aborts at the time budget instead of running to completion in one native step. -constexpr int kDeepCopyMaxDepth = 1000; - -value_t deep_copy_impl(const value_t &v, std::unordered_map &memo, - int depth) { - if (eval_deadline_expired()) - throw std::runtime_error("state-data-get: evaluation exceeded time budget"); - if (depth > kDeepCopyMaxDepth) - throw std::runtime_error("state-data-get: value nested too deeply to copy"); - if (auto *l = std::get_if(&v.v)) { - if (!*l) - return v; - auto found = memo.find(static_cast(l->get())); - if (found != memo.end()) - return found->second; // shared node / cycle already copied -> reuse (bounds the walk) - auto out = std::make_shared>(); - value_t result{out}; - memo.emplace(static_cast(l->get()), result); // register BEFORE recursing - out->reserve((*l)->size()); - for (const auto &e : **l) - out->push_back(deep_copy_impl(e, memo, depth + 1)); - return result; - } - if (auto *dp = std::get_if(&v.v)) { - if (!*dp) - return v; - auto found = memo.find(static_cast(dp->get())); - if (found != memo.end()) - return found->second; - auto out = std::make_shared>>(); - value_t result{out}; - memo.emplace(static_cast(dp->get()), result); - out->reserve((*dp)->size()); - for (const auto &kv : **dp) - out->emplace_back(kv.first, deep_copy_impl(kv.second, memo, depth + 1)); - return result; - } - return v; // scalar / symbol (opaque callables are refused at state-data-set) -} - +// sharing, disjoint from the stored original) and a cycle terminates; it keeps the elements +// still to copy on a heap stack, so any depth copies without native recursion; and it polls +// the evaluation deadline so a huge payload aborts at the time budget instead of running to +// completion in one native step. value_t deep_copy(const value_t &v) { std::unordered_map memo; - return deep_copy_impl(v, memo, 0); + value_t result; + // (source, destination) pairs: each destination is a slot of a copy already allocated at its + // final size, so it stays put while the copy fills in. + std::vector> pending{{&v, &result}}; + while (!pending.empty()) { + if (eval_deadline_expired()) + throw std::runtime_error("state-data-get: evaluation exceeded time budget"); + auto [src, dst] = pending.back(); + pending.pop_back(); + if (auto *l = std::get_if(&src->v); l && *l) { + auto found = memo.find(static_cast(l->get())); + if (found != memo.end()) { + *dst = found->second; // shared node / cycle already copied -> reuse (bounds the walk) + continue; + } + auto out = std::make_shared>((*l)->size()); + *dst = value_t{out}; + memo.emplace(static_cast(l->get()), *dst); // register BEFORE descending + for (std::size_t i = (*l)->size(); i-- > 0;) + pending.emplace_back(&(**l)[i], &(*out)[i]); + } else if (auto *dp = std::get_if(&src->v); dp && *dp) { + auto found = memo.find(static_cast(dp->get())); + if (found != memo.end()) { + *dst = found->second; + continue; + } + auto out = std::make_shared>>(); + out->reserve((*dp)->size()); + for (const auto &kv : **dp) + out->emplace_back(kv.first, value_t{}); + *dst = value_t{out}; + memo.emplace(static_cast(dp->get()), *dst); + for (std::size_t i = (*dp)->size(); i-- > 0;) + pending.emplace_back(&(**dp)[i].second, &(*out)[i].second); + } else { + *dst = *src; // scalar / symbol / null (opaque callables are refused at state-data-set) + } + } + return result; } void require_root(const intrinsics_context *ctx, const char *name) { diff --git a/src/cvc/state/state_exec/stackless_evaluator.cpp b/src/cvc/state/state_exec/stackless_evaluator.cpp index ec55b1e5e..c31932f28 100644 --- a/src/cvc/state/state_exec/stackless_evaluator.cpp +++ b/src/cvc/state/state_exec/stackless_evaluator.cpp @@ -1081,22 +1081,27 @@ void stackless_evaluator::handle_defclass(evaluator_state &state, eval_frame &fr value_t stackless_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)}; + // Like the evaluator, the copy keeps its work on the heap, not the native stack, so a + // template of any depth expands. (source, destination) pairs: each destination is a slot of + // a list copy allocated at its final size, so it stays put while the copy fills in. + value_t result; + std::vector> pending{{&tmpl, &result}}; + while (!pending.empty()) { + auto [src, dst] = pending.back(); + pending.pop_back(); + if (auto *sym = std::get_if(&src->v)) { + auto it = subst.find(sym->name); + *dst = it != subst.end() ? it->second : *src; + } else if (auto *lp = std::get_if(&src->v); lp && *lp) { + auto copy = std::make_shared>((*lp)->size()); + *dst = value_t{copy}; + for (std::size_t i = (*lp)->size(); i-- > 0;) + pending.emplace_back(&(**lp)[i], &(*copy)[i]); + } else { + *dst = *src; + } } - return tmpl; + return result; } } // namespace cvc::state_exec diff --git a/src/cvc/state/state_exec/state_value_codec.cpp b/src/cvc/state/state_exec/state_value_codec.cpp index 6d5c9c5bf..527525a12 100644 --- a/src/cvc/state/state_exec/state_value_codec.cpp +++ b/src/cvc/state/state_exec/state_value_codec.cpp @@ -5,12 +5,16 @@ cvc::state subtrees. */ +#include #include #include #include #include +#include #include #include +#include +#include namespace cvc::state_exec { @@ -94,13 +98,113 @@ std::string b64_decode(const std::string &in) { return out; } +// Nested lists and dicts are written and read with explicit stacks, not native recursion, and +// the state subtree they make is kept shallow too: cvc::state recurses once per TREE level on +// every write (fullName() builds the path parent by parent, childChanged() climbs to the root), +// so a list nested 100000 deep must not become a subtree 200000 deep. A list or dict +// kSpillDepth levels below where its stretch of nesting began is written into the __spill__ +// table under the node encode_value was called on, as an entry of its own, and the place it +// would have gone holds a reference: __type__ "spill_ref", value the entry's index. Its own +// nesting starts again from that entry. Values at most kSpillDepth levels deep are written +// exactly as before, with no table. +// +// 8 levels make at most ~30 tree levels per stretch (a dict level is 3: __entries__, the +// index, __val__). A write that deep, under a snapshot root ~5 levels down, fits a 64 KiB +// stack (wasm's); with 16, the dicts' ~55 did not. +constexpr std::size_t kSpillDepth = 8; +const char *SPILL_KEY = "__spill__"; + +// The non-null list or dict a value holds, or null. +const void *container_of(const value_t &v) { + if (auto *l = std::get_if(&v.v)) + return l->get(); + if (auto *d = std::get_if(&v.v)) + return d->get(); + return nullptr; +} + +// Write a value that is not a non-null list or dict. +void encode_leaf(cvc::state &node, const value_t &val); + +// Read a value whose __type__ is `type`, which is not "list", "dict" or "spill_ref". +value_t decode_leaf(cvc::state &node, const std::string &type); + } // namespace // --------------------------------------------------------------------------- // encode_value // --------------------------------------------------------------------------- -void encode_value(cvc::state &node, const value_t &val) { +void encode_value(cvc::state &root, const value_t &val) { + // A list or dict whose elements are being written. + struct frame { + const value_t *val; + cvc::state *items; // its __elems__ or __entries__ node + std::size_t next; // the next element to write + std::size_t size; // its length, as written to __len__ + std::size_t nest; // how many levels below its stretch's start it is + }; + std::vector stack; + std::unordered_set open; // the lists and dicts on `stack` + std::optional spill; + + // Write `v` into `node`: a leaf now, a list or dict by its type and a frame for its elements. + auto begin = [&](cvc::state &node, const value_t &v, std::size_t nest) { + const void *c = container_of(v); + if (!c) { + encode_leaf(node, v); + return; + } + if (!open.insert(c).second) + throw std::runtime_error("encode_value: the value contains itself"); + cvc::state *target = &node; + if (nest >= kSpillDepth) { + if (!spill) + spill.emplace(root(SPILL_KEY)); + set_type(node, "spill_ref"); + node.value(std::to_string(spill->size())); + target = &spill->push_back(); + nest = 0; + } + const bool is_list = std::holds_alternative(v.v); + set_type(*target, is_list ? "list" : "dict"); + cvc::state &items = (*target)(is_list ? "__elems__" : "__entries__"); + // The length is known up front, so it is written once, not once per element as + // state_list::push_back would (every write signals each ancestor). An empty list or dict + // gets the "0" that state_list's constructor writes, as before. + const std::size_t size = + is_list ? std::get(v.v)->size() : std::get(v.v)->size(); + if (size > 0) + items("__len__").value(size); + else + (void)state_list(items); + stack.push_back({&v, &items, 0, size, nest}); + }; + + begin(root, val, 0); + while (!stack.empty()) { + frame &f = stack.back(); + if (f.next == f.size) { + open.erase(container_of(*f.val)); + stack.pop_back(); + continue; + } + const std::size_t i = f.next++; + const std::size_t nest = f.nest + 1; + cvc::state &slot = state_list(*f.items).at(i); + if (auto *l = std::get_if(&f.val->v)) { + begin(slot, (**l)[i], nest); // may push, invalidating f + } else { + const auto &[k, v] = (*std::get(f.val->v))[i]; + slot("__key__").value(k); + begin(slot("__val__"), v, nest); // may push, invalidating f + } + } +} + +namespace { + +void encode_leaf(cvc::state &node, const value_t &val) { std::visit( [&](auto &&arg) { using T = std::decay_t; @@ -126,25 +230,9 @@ void encode_value(cvc::state &node, const value_t &val) { set_type(node, "symbol"); node.value(arg.name); } else if constexpr (std::is_same_v) { - set_type(node, "list"); - if (arg) { - state_list sl(node("__elems__")); - for (auto &e : *arg) { - auto &elem = sl.push_back(); - encode_value(elem, e); - } - } + set_type(node, "list"); // null: a non-null list is encode_value's } else if constexpr (std::is_same_v) { - set_type(node, "dict"); - if (arg) { - auto &entries_node = node("__entries__"); - state_list sl(entries_node); - for (auto &[k, v] : *arg) { - auto &entry = sl.push_back(); - entry("__key__").value(k); - encode_value(entry("__val__"), v); - } - } + set_type(node, "dict"); // null: a non-null dict is encode_value's } else if constexpr (std::is_same_v) { set_type(node, "closure"); if (arg) { @@ -186,13 +274,96 @@ void encode_value(cvc::state &node, const value_t &val) { val.v); } +} // namespace + // --------------------------------------------------------------------------- // decode_value // --------------------------------------------------------------------------- -value_t decode_value(cvc::state &node) { - std::string type = get_type(node); +value_t decode_value(cvc::state &root) { + // A list or dict whose elements are being read. + struct frame { + cvc::state *items; // its __elems__ or __entries__ node + std::size_t next; // the next element to read + std::size_t size; + std::vector *list; + ordered_map *dict; + }; + std::vector stack; + std::optional spill; + std::vector spill_used; // each entry is referenced once; a second reference is corrupt + + // Read the value at `node` into `out`: a leaf now, a list or dict as an empty container that + // a frame fills. + auto begin = [&](cvc::state &node, value_t &out) { + cvc::state *src = &node; + std::string type = get_type(node); + if (type == "spill_ref") { + if (!spill) { + if (!has_child(root, SPILL_KEY)) + throw std::runtime_error("decode_value: spill_ref without a spill table"); + spill.emplace(root(SPILL_KEY)); + spill_used.assign(spill->size(), false); + } + const std::string ref = node.value(); + std::size_t i = 0; + const auto [end, ec] = std::from_chars(ref.data(), ref.data() + ref.size(), i); + if (ec != std::errc{} || end != ref.data() + ref.size() || i >= spill_used.size() || + spill_used[i]) + throw std::runtime_error("decode_value: bad spill_ref: " + ref); + spill_used[i] = true; + src = &spill->at(i); + type = get_type(*src); + if (type != "list" && type != "dict") + throw std::runtime_error("decode_value: spill entry " + ref + " is not a list or dict"); + } + if (type == "list") { + auto elems = std::make_shared>(); + out = value_t(elems); + if (has_child(*src, "__elems__")) { + cvc::state &items = (*src)("__elems__"); + const std::size_t n = state_list(items).size(); + elems->reserve(n); + stack.push_back({&items, 0, n, elems.get(), nullptr}); + } + } else if (type == "dict") { + auto entries = std::make_shared(); + out = value_t(entries); + if (has_child(*src, "__entries__")) { + cvc::state &items = (*src)("__entries__"); + const std::size_t n = state_list(items).size(); + entries->reserve(n); + stack.push_back({&items, 0, n, nullptr, entries.get()}); + } + } else { + out = decode_leaf(*src, type); + } + }; + + value_t result; + begin(root, result); + while (!stack.empty()) { + frame &f = stack.back(); + if (f.next == f.size) { + stack.pop_back(); + continue; + } + cvc::state &item = state_list(*f.items).at(f.next++); + if (f.list) { + value_t &slot = f.list->emplace_back(); + begin(item, slot); // may push, invalidating f + } else { + std::string key = item("__key__").value(); + value_t &slot = f.dict->emplace_back(std::move(key), value_t{}).second; + begin(item("__val__"), slot); // may push, invalidating f + } + } + return result; +} + +namespace { +value_t decode_leaf(cvc::state &node, const std::string &type) { if (type.empty() || type == "nil") return nil_value; @@ -214,32 +385,6 @@ value_t decode_value(cvc::state &node) { if (type == "symbol") return value_t(symbol{node.value()}); - if (type == "list") { - auto elems = std::make_shared>(); - if (has_child(node, "__elems__")) { - state_list sl(node("__elems__")); - elems->reserve(sl.size()); - for (size_t i = 0; i < sl.size(); ++i) - elems->push_back(decode_value(sl.at(i))); - } - return value_t(std::move(elems)); - } - - if (type == "dict") { - auto entries = std::make_shared>>(); - if (has_child(node, "__entries__")) { - state_list sl(node("__entries__")); - entries->reserve(sl.size()); - for (size_t i = 0; i < sl.size(); ++i) { - auto &entry = sl.at(i); - std::string key = entry("__key__").value(); - value_t val = decode_value(entry("__val__")); - entries->emplace_back(std::move(key), std::move(val)); - } - } - return value_t(std::move(entries)); - } - if (type == "closure") { auto cls = std::make_shared(); // Params @@ -279,6 +424,8 @@ value_t decode_value(cvc::state &node) { throw std::runtime_error("decode_value: unknown type: " + type); } +} // namespace + // --------------------------------------------------------------------------- // encode_environment // --------------------------------------------------------------------------- diff --git a/src/cvc/state/state_exec/types.cpp b/src/cvc/state/state_exec/types.cpp index 7369a0977..00865238c 100644 --- a/src/cvc/state/state_exec/types.cpp +++ b/src/cvc/state/state_exec/types.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -135,6 +136,77 @@ bool eval_deadline_expired() { return spent; } +// -- releasing nested values ------------------------------------------------- + +namespace { +// The owners a release has yet to free: the first few in uninitialized inline slots, the rest +// on the heap, so freeing an ordinary small value constructs and allocates nothing extra. +class release_queue { +public: + release_queue() = default; + release_queue(const release_queue &) = delete; + release_queue &operator=(const release_queue &) = delete; + ~release_queue() { + while (count_ > 0) + slot(--count_).~shared_ptr(); + } + + // Takes `owner`, unless out of memory: then it is left with the caller. + void push(std::shared_ptr &owner) noexcept { + if (count_ < kInline) { + new (&slot(count_++)) std::shared_ptr(std::move(owner)); + return; + } + try { + heap_.push_back(std::move(owner)); + } catch (...) { + // push_back left `owner` as it was. + } + } + bool pop(std::shared_ptr &out) noexcept { + if (!heap_.empty()) { + out = std::move(heap_.back()); + heap_.pop_back(); + return true; + } + if (count_ == 0) + return false; + std::shared_ptr &s = slot(--count_); + out = std::move(s); + s.~shared_ptr(); + return true; + } + +private: + static constexpr std::size_t kInline = 8; + std::shared_ptr &slot(std::size_t i) { + return *std::launder(reinterpret_cast *>(inline_) + i); + } + alignas(std::shared_ptr) unsigned char inline_[kInline * sizeof(std::shared_ptr)]; + std::size_t count_ = 0; + std::vector> heap_; +}; + +// The queue of the release running on this thread, if one is: release_nested() called while it +// frees something (from that thing's destructor) queues its owner there instead of freeing it +// inline. A raw pointer to the running release's local queue, so no thread-exit destructor is +// involved and nothing outlives the release. +thread_local release_queue *t_release_queue = nullptr; +} // namespace + +void detail::release_nested(std::shared_ptr owner) noexcept { + if (release_queue *queue = t_release_queue) { + queue->push(owner); // out of memory: `owner` is still here, and freed here, recursively + return; + } + release_queue queue; + t_release_queue = &queue; + owner.reset(); + for (std::shared_ptr next; queue.pop(next);) + next.reset(); // a no-op unless this was the last owner + t_release_queue = nullptr; +} + // -- value_tag --------------------------------------------------------------- std::string value_tag::type_name() const { @@ -175,35 +247,35 @@ std::string value_tag::type_name() const { // -- environment ------------------------------------------------------------- +environment::~environment() { detail::drop_owner(outer); } + value_t *environment::lookup(const std::string &name) { - auto it = bindings.find(name); - if (it != bindings.end()) - return &it->second; - if (outer) - return outer->lookup(name); + for (environment *e = this; e; e = e->outer.get()) { + auto it = e->bindings.find(name); + if (it != e->bindings.end()) + return &it->second; + } return nullptr; } const value_t *environment::lookup(const std::string &name) const { - auto it = bindings.find(name); - if (it != bindings.end()) - return &it->second; - if (outer) - return outer->lookup(name); + for (const environment *e = this; e; e = e->outer.get()) { + auto it = e->bindings.find(name); + if (it != e->bindings.end()) + return &it->second; + } return nullptr; } void environment::set(const std::string &name, value_t val) { bindings[name] = std::move(val); } void environment::set_existing(const std::string &name, value_t val) { - auto it = bindings.find(name); - if (it != bindings.end()) { - it->second = std::move(val); - return; - } - if (outer) { - outer->set_existing(name, std::move(val)); - return; + for (environment *e = this; e; e = e->outer.get()) { + auto it = e->bindings.find(name); + if (it != e->bindings.end()) { + it->second = std::move(val); + return; + } } // Not found anywhere — set in this scope bindings[name] = std::move(val); @@ -218,16 +290,26 @@ environment_ptr environment::extend(environment_ptr parent) { // -- to_string --------------------------------------------------------------- namespace { -// Bounds for to_string: cap total OUTPUT length (a shared/wide structure would otherwise -// expand exponentially) and recursion DEPTH (a deep or cyclic structure would otherwise -// overflow the C++ stack). Past either, emit an ellipsis and stop — enough for debug / -// coercion / error messages, and impossible to weaponise into a hang or OOM. +// to_string caps total OUTPUT length (a shared/wide structure would otherwise expand +// exponentially, a cyclic one forever); past it, it emits an ellipsis and stops -- enough for +// debug / coercion / error messages, and impossible to weaponise into a hang or OOM. Depth +// needs no cap of its own: the walk keeps its open lists and dicts on a heap stack, not the +// native one, and each level opened costs output, so the length cap bounds that stack too. constexpr std::size_t kToStringMaxLen = std::size_t{1} << 16; // 64 KiB -constexpr int kToStringMaxDepth = 1000; +constexpr const char *kEllipsis = "\xE2\x80\xA6"; // U+2026 HORIZONTAL ELLIPSIS -void to_string_impl(const value_t &val, std::string &out, int depth) { - if (out.size() >= kToStringMaxLen || depth > kToStringMaxDepth) { - out += "\xE2\x80\xA6"; // U+2026 HORIZONTAL ELLIPSIS +// A list or dict to_string has opened and not yet closed. +struct print_frame { + const std::vector *list = nullptr; + const ordered_map *dict = nullptr; + std::size_t next = 0; // the next element to print +}; + +// Print `val`; a non-empty list or dict is only opened (its bracket printed and a frame pushed +// for to_string's loop to print its elements and close it). +void print_value(const value_t &val, std::string &out, std::vector &open) { + if (out.size() >= kToStringMaxLen) { + out += kEllipsis; return; } std::visit( @@ -274,16 +356,7 @@ void to_string_impl(const value_t &val, std::string &out, int depth) { return; } out += '('; - for (std::size_t i = 0; i < arg->size(); ++i) { - if (i > 0) - out += ' '; - if (out.size() >= kToStringMaxLen) { - out += "\xE2\x80\xA6"; - break; - } - to_string_impl((*arg)[i], out, depth + 1); - } - out += ')'; + open.push_back({arg.get(), nullptr, 0}); } else if constexpr (std::is_same_v) out += ""; else if constexpr (std::is_same_v) { @@ -292,19 +365,7 @@ void to_string_impl(const value_t &val, std::string &out, int depth) { return; } out += '{'; - for (std::size_t i = 0; i < arg->size(); ++i) { - if (i > 0) - out += ", "; - if (out.size() >= kToStringMaxLen) { - out += "\xE2\x80\xA6"; - break; - } - out += '"'; - out += (*arg)[i].first; - out += "\": "; - to_string_impl((*arg)[i].second, out, depth + 1); - } - out += '}'; + open.push_back({nullptr, arg.get(), 0}); } else if constexpr (std::is_same_v) out += ""; else if constexpr (std::is_same_v) @@ -320,7 +381,35 @@ void to_string_impl(const value_t &val, std::string &out, int depth) { std::string to_string(const value_t &val) { std::string out; - to_string_impl(val, out, 0); + std::vector open; + print_value(val, out, open); + while (!open.empty()) { + print_frame &f = open.back(); + const std::size_t i = f.next++; + const bool is_list = f.list != nullptr; + if (i == (is_list ? f.list->size() : f.dict->size())) { + out += is_list ? ')' : '}'; + open.pop_back(); + continue; + } + if (i > 0) + out += is_list ? " " : ", "; + if (out.size() >= kToStringMaxLen) { + out += kEllipsis; + out += is_list ? ')' : '}'; + open.pop_back(); + continue; + } + if (is_list) { + print_value((*f.list)[i], out, open); // may push, invalidating f + } else { + const auto &[key, value] = (*f.dict)[i]; + out += '"'; + out += key; + out += "\": "; + print_value(value, out, open); // may push, invalidating f + } + } return out; } @@ -333,19 +422,13 @@ struct ptr_pair_hash { } }; using ptr_pair_set = std::unordered_set, ptr_pair_hash>; +using value_pairs = std::vector>; -constexpr int kValuesEqualMaxDepth = 1000; // guard the C++ stack on a deeply-nested compare - -// `seen` memoizes the (a,b) pointer-pairs already under comparison. Re-encountering a pair -// (a shared node reached by two paths, or a cycle) returns true co-inductively: this bounds -// work to the number of DISTINCT node-pairs (the physical size), never the exponential -// logical unfolding of a shared DAG, and terminates on cyclic structures instead of -// recursing forever. For finite acyclic values the result is identical to a naive compare. -// A depth cap + deadline poll keep even a deeply-nested or huge NON-shared structure bounded -// (memoization alone does not: distinct nodes are never re-encountered). -bool values_equal_impl(const value_t &a, const value_t &b, ptr_pair_set &seen, int depth) { - if (depth > kValuesEqualMaxDepth || eval_deadline_expired()) - throw std::runtime_error("values_equal: structure too deep or large to compare"); +// Whether a and b are equal as far as one level shows; the element pairs a list or dict pair +// still needs compared are pushed onto `pending`. `seen` memoizes the container pairs already +// under comparison: re-encountering one (a shared node reached by two paths, or a cycle) +// counts as equal co-inductively. +bool shallow_equal(const value_t &a, const value_t &b, ptr_pair_set &seen, value_pairs &pending) { if (a.v.index() != b.v.index()) return false; return std::visit( @@ -375,9 +458,8 @@ bool values_equal_impl(const value_t &a, const value_t &b, ptr_pair_set &seen, i static_cast(arg_b.get())}) .second) return true; // this pair is already being compared (shared/cyclic) -> equal - for (std::size_t i = 0; i < arg_a->size(); ++i) - if (!values_equal_impl((*arg_a)[i], (*arg_b)[i], seen, depth + 1)) - return false; + for (std::size_t i = arg_a->size(); i-- > 0;) + pending.emplace_back(&(*arg_a)[i], &(*arg_b)[i]); return true; } else if constexpr (std::is_same_v) return arg_a == arg_b; // identity comparison @@ -392,12 +474,11 @@ bool values_equal_impl(const value_t &a, const value_t &b, ptr_pair_set &seen, i static_cast(arg_b.get())}) .second) return true; - for (std::size_t i = 0; i < arg_a->size(); ++i) { + for (std::size_t i = 0; i < arg_a->size(); ++i) if ((*arg_a)[i].first != (*arg_b)[i].first) return false; - if (!values_equal_impl((*arg_a)[i].second, (*arg_b)[i].second, seen, depth + 1)) - return false; - } + for (std::size_t i = arg_a->size(); i-- > 0;) + pending.emplace_back(&(*arg_a)[i].second, &(*arg_b)[i].second); return true; } else if constexpr (std::is_same_v) return false; // functions are never equal @@ -414,9 +495,23 @@ bool values_equal_impl(const value_t &a, const value_t &b, ptr_pair_set &seen, i } } // namespace +// The memo bounds work to the number of DISTINCT node-pairs (the physical size), never the +// exponential logical unfolding of a shared DAG, and terminates on cyclic structures; for +// finite acyclic values the result is identical to a naive compare. The pairs still to compare +// wait on a heap stack, so depth costs no native stack. A deadline poll bounds a huge +// NON-shared structure (memoization alone does not: distinct nodes are never re-encountered). bool values_equal(const value_t &a, const value_t &b) { ptr_pair_set seen; - return values_equal_impl(a, b, seen, 0); + value_pairs pending{{&a, &b}}; + while (!pending.empty()) { + if (eval_deadline_expired()) + throw std::runtime_error("values_equal: structure too large to compare"); + const auto [x, y] = pending.back(); + pending.pop_back(); + if (!shallow_equal(*x, *y, seen, pending)) + return false; + } + return true; } } // namespace cvc::state_exec diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 316fc1dc3..48a557a88 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -74,6 +74,7 @@ add_executable(state_exec_bench_test state_exec_bench_test.cpp) add_executable(state_exec_integration_test state_exec_integration_test.cpp) add_executable(state_exec_advanced_integration_test state_exec_advanced_integration_test.cpp) add_executable(state_exec_gaps_test state_exec_gaps_test.cpp) +add_executable(state_exec_deep_data_test state_exec_deep_data_test.cpp) if(NOT WIN32) add_executable(state_exec_multiprocess_test state_exec_multiprocess_test.cpp) endif() @@ -341,6 +342,7 @@ list(APPEND TEST_TARGETS state_exec_integration_test state_exec_advanced_integration_test state_exec_gaps_test + state_exec_deep_data_test ) if(NOT WIN32) list(APPEND TEST_TARGETS state_exec_multiprocess_test) @@ -1215,6 +1217,13 @@ target_link_libraries(state_exec_types_test GTest::gtest_main ) +target_link_libraries(state_exec_deep_data_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(state_exec_builtins_test PRIVATE cvc @@ -1581,6 +1590,7 @@ target_compile_features(state_cross_cluster_link_test PRIVATE cxx_std_17) target_compile_features(state_link_bench_test PRIVATE cxx_std_17) target_compile_features(state_exec_parser_test PRIVATE cxx_std_20) target_compile_features(state_exec_types_test PRIVATE cxx_std_20) +target_compile_features(state_exec_deep_data_test PRIVATE cxx_std_20) target_compile_features(state_exec_builtins_test PRIVATE cxx_std_20) target_compile_features(state_exec_evaluator_test PRIVATE cxx_std_20) target_compile_features(state_exec_stackless_evaluator_test PRIVATE cxx_std_20) @@ -1992,6 +2002,7 @@ cvc_discover_tests(state_cross_cluster_link_test) cvc_discover_tests(state_link_bench_test) cvc_discover_tests(state_exec_parser_test) cvc_discover_tests(state_exec_types_test) +cvc_discover_tests(state_exec_deep_data_test) cvc_discover_tests(state_exec_builtins_test) cvc_discover_tests(state_exec_evaluator_test) cvc_discover_tests(state_exec_stackless_evaluator_test) diff --git a/src/cvc/tests/state_exec_bench_test.cpp b/src/cvc/tests/state_exec_bench_test.cpp index f22725734..52b1fef8f 100644 --- a/src/cvc/tests/state_exec_bench_test.cpp +++ b/src/cvc/tests/state_exec_bench_test.cpp @@ -131,6 +131,60 @@ TEST_F(ExecBenchTest, EvalFibonacci) { report("eval_fib15", N, std::chrono::duration_cast(t1 - t0)); } +TEST_F(ExecBenchTest, ValueDropThroughput) { + if (!bench_enabled()) { + SUCCEED(); + return; + } + + // Building and dropping values: the release path every list/dict/closure takes. + constexpr int64_t N = 1000000; + auto t0 = hrclock::now(); + for (int64_t i = 0; i < N; ++i) { + value_t v = make_list({value_t{i}, make_list({value_t{i}}), make_dict({{"k", value_t{i}}})}); + (void)v; + } + auto t1 = hrclock::now(); + report("value_drop_small", N, std::chrono::duration_cast(t1 - t0)); + + // Copies dropped while another owner lives. + const value_t shared = make_list({value_t{int64_t{1}}, value_t{int64_t{2}}}); + std::vector copies(64); + t0 = hrclock::now(); + for (int64_t i = 0; i < N; ++i) + copies[static_cast(i % 64)] = shared; + t1 = hrclock::now(); + report("value_drop_shared_copy", N, std::chrono::duration_cast(t1 - t0)); + + // 1000-level lists, built and dropped. + constexpr int64_t kDepth = 1000, kReps = 1000; + t0 = hrclock::now(); + for (int64_t r = 0; r < kReps; ++r) { + value_t v = make_list({value_t{r}}); + for (int64_t i = 1; i < kDepth; ++i) + v = make_list({std::move(v)}); + } + t1 = hrclock::now(); + report("value_drop_deep1000", kDepth * kReps, std::chrono::duration_cast(t1 - t0)); + + // The same from the DSL: a list nested 2000 deep, dropped when the evaluator is. + const std::string src = "(begin " + " (set acc (list)) " + " (set i 0) " + " (while (< i 2000) " + " (begin (set acc (list i acc)) (set i (+ i 1)))) " + " i)"; + auto env = builtins::make_default_environment(); + constexpr int M = 50; + t0 = hrclock::now(); + for (int i = 0; i < M; ++i) { + stackless_evaluator ev(env); + ev.evaluate_script(src); + } + t1 = hrclock::now(); + report("eval_nest_list_2000", M, std::chrono::duration_cast(t1 - t0)); +} + TEST_F(ExecBenchTest, SchedulerThroughput) { if (!bench_enabled()) { SUCCEED(); diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp new file mode 100644 index 000000000..98a46d6af --- /dev/null +++ b/src/cvc/tests/state_exec_deep_data_test.cpp @@ -0,0 +1,390 @@ +// 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 +// 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 +// here on every platform, whatever the main thread's stack is. +// +// For probing, CVC_DEEP_DATA_DEPTH overrides the depths and CVC_DEEP_DATA_STACK_KIB the stack +// (0 runs the body on the calling thread). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace cvc::state_exec; + +namespace { + +constexpr std::size_t kDepth = 100000; +// The snapshot codec writes cvc::state nodes, and each write signals every ancestor, so a level +// costs tens of microseconds: 10000 levels keep the codec tests to seconds, and are still far +// past the 441 levels the recursive codec survived on a 1 MiB stack (104 on 256 KiB). +constexpr std::size_t kCodecDepth = 10000; +constexpr std::size_t kStackKiB = 256; + +std::size_t env_size(const char *name, std::size_t fallback) { + const char *s = std::getenv(name); + return s && *s ? static_cast(std::strtoull(s, nullptr, 10)) : fallback; +} + +std::size_t depth() { return env_size("CVC_DEEP_DATA_DEPTH", kDepth); } +std::size_t codec_depth() { return env_size("CVC_DEEP_DATA_DEPTH", kCodecDepth); } + +// Run `body` on a thread with a kStackKiB stack and rethrow what it throws. +void on_small_stack(const std::function &body) { + const std::size_t kib = env_size("CVC_DEEP_DATA_STACK_KIB", kStackKiB); + if (kib == 0) { + body(); + return; + } + std::exception_ptr error; + boost::thread::attributes attrs; + attrs.set_stack_size(kib * 1024); + boost::thread t(attrs, [&] { + try { + body(); + } catch (...) { + error = std::current_exception(); + } + }); + t.join(); + if (error) + std::rethrow_exception(error); +} + +// (((... (leaf) ...))): `n` lists, the innermost holding `leaf`. +value_t nested_list(std::size_t n, value_t leaf = value_t{int64_t{1}}) { + value_t v = make_list({std::move(leaf)}); + for (std::size_t i = 1; i < n; ++i) + v = make_list({std::move(v)}); + return v; +} + +// {"k": {"k": ... {"k": 1} ...}}: `n` dicts. +value_t nested_dict(std::size_t n) { + value_t v = make_dict({{"k", value_t{int64_t{1}}}}); + for (std::size_t i = 1; i < n; ++i) + v = make_dict({{"k", std::move(v)}}); + return v; +} + +// Lists and dicts alternating, each level also holding a scalar sibling. +value_t nested_mixed(std::size_t n) { + value_t v = value_t{int64_t{0}}; + for (std::size_t i = 0; i < n; ++i) + v = i % 2 ? make_dict({{"i", value_t{int64_t(i)}}, {"v", std::move(v)}}) + : make_list({value_t{std::string("s")}, std::move(v)}); + return v; +} + +// The innermost list of nested_list(), as a weak pointer: expired once the value is freed. +std::weak_ptr> innermost(const value_t &v) { + const value_t *cur = &v; + list_ptr last; + while (auto *l = std::get_if(&cur->v)) { + last = *l; + if ((*l)->empty()) + break; + cur = &(*l)->front(); + } + return last; +} + +// How many lists deep a nested_list() is, walked iteratively. +std::size_t list_depth(const value_t &v) { + std::size_t n = 0; + const value_t *cur = &v; + while (auto *l = std::get_if(&cur->v)) { + ++n; + if (!*l || (*l)->empty()) + break; + cur = &(*l)->front(); + } + return n; +} + +// The paths of every node under `node`, relative to it (state::children() lists all +// descendants, by full name). +std::vector descendants(cvc::state &node) { + const std::size_t prefix = node.fullName().size() + 1; + std::vector paths; + for (const std::string &full : node.children()) + paths.push_back(full.substr(prefix)); + return paths; +} + +// The depth of the state subtree under `node`. +std::size_t state_depth(cvc::state &node) { + std::size_t deepest = 0; + for (const std::string &path : descendants(node)) + deepest = std::max(deepest, 1 + std::count(path.begin(), path.end(), '.')); + return deepest; +} + +// ---- drop ------------------------------------------------------------------ + +TEST(StateExecDeepData, DropList) { + on_small_stack([] { + value_t v = nested_list(depth()); + auto last = innermost(v); + v = value_t{}; + EXPECT_TRUE(last.expired()); + }); +} + +TEST(StateExecDeepData, DropListOutOfScope) { + on_small_stack([] { + std::weak_ptr> last; + { + value_t v = nested_list(depth()); + last = innermost(v); + } + EXPECT_TRUE(last.expired()); + }); +} + +TEST(StateExecDeepData, DropDict) { + on_small_stack([] { value_t v = nested_dict(depth()); }); +} + +TEST(StateExecDeepData, DropMixed) { + on_small_stack([] { value_t v = nested_mixed(depth()); }); +} + +TEST(StateExecDeepData, DropBareListPtr) { + // The last owner is a list_ptr, not a value_t: the vector's elements free themselves. + on_small_stack([] { + list_ptr l = std::get(nested_list(depth()).v); + l.reset(); + }); +} + +TEST(StateExecDeepData, DropKeepsSharedTail) { + on_small_stack([] { + const std::size_t n = depth(); + value_t tail = nested_list(n); + value_t a = make_list({tail}); + value_t b = make_list({tail}); + tail = value_t{}; + a = value_t{}; + EXPECT_EQ(list_depth(b), n + 1); // still whole: `a` only dropped its reference + auto last = innermost(b); + b = value_t{}; + EXPECT_TRUE(last.expired()); + }); +} + +TEST(StateExecDeepData, DropClosureChain) { + // closure -> env_snapshot -> binding -> closure -> ...: the chain a recursive function that + // returns a closure over its inner call's closure builds at runtime. + on_small_stack([] { + value_t v; + for (std::size_t i = 0; i < depth(); ++i) { + auto env = std::make_shared(); + env->set("next", std::move(v)); + auto c = std::make_shared(); + c->env_snapshot = std::move(env); + v = value_t{c}; + } + }); +} + +TEST(StateExecDeepData, DropEnvironmentChain) { + on_small_stack([] { + environment_ptr env; + for (std::size_t i = 0; i < depth(); ++i) + env = environment::extend(std::move(env)); + std::weak_ptr inner = env; + env.reset(); + EXPECT_TRUE(inner.expired()); + }); +} + +// ---- to_string ------------------------------------------------------------- + +TEST(StateExecDeepData, ToStringIsBounded) { + on_small_stack([] { + value_t v = nested_list(depth()); + const std::string s = to_string(v); + // Output is capped at 64 KiB (plus the closing parens of what was opened), never per + // level of depth. + EXPECT_LE(s.size(), std::size_t{2} * 65536 + 16); + EXPECT_EQ(s.front(), '('); + EXPECT_NE(s.find("\xE2\x80\xA6"), std::string::npos); + }); +} + +TEST(StateExecDeepData, ToStringPrintsDeepValueWithinLengthCap) { + // 5000 levels print in ~10 KB, under the length cap, so they print whole: depth alone + // never truncates. + on_small_stack([] { + const std::size_t n = 5000; + EXPECT_EQ(to_string(nested_list(n)), std::string(n, '(') + "1" + std::string(n, ')')); + }); +} + +TEST(StateExecDeepData, ToStringDeepDict) { + on_small_stack([] { + const std::string s = to_string(nested_dict(depth())); + EXPECT_LE(s.size(), std::size_t{3} * 65536); + EXPECT_EQ(s.rfind("{\"k\": {\"k\": ", 0), 0u); + }); +} + +// ---- values_equal ---------------------------------------------------------- + +TEST(StateExecDeepData, ValuesEqualDeepLists) { + on_small_stack([] { + const std::size_t n = depth(); + EXPECT_TRUE(values_equal(nested_list(n), nested_list(n))); + EXPECT_FALSE(values_equal(nested_list(n), nested_list(n, value_t{int64_t{2}}))); + EXPECT_FALSE(values_equal(nested_list(n), nested_list(n - 1))); + }); +} + +TEST(StateExecDeepData, ValuesEqualDeepDicts) { + on_small_stack([] { + const std::size_t n = depth(); + EXPECT_TRUE(values_equal(nested_dict(n), nested_dict(n))); + EXPECT_TRUE(values_equal(nested_mixed(n), nested_mixed(n))); + EXPECT_FALSE(values_equal(nested_mixed(n), nested_mixed(n - 1))); + }); +} + +// ---- macro expansion ------------------------------------------------------- + +TEST(StateExecDeepData, MacroExpandsDeepTemplate) { + // (begin (defmacro m (x) (quote (((... x ...))))) (m 7)), the template built at runtime. + on_small_stack([] { + const std::size_t n = depth(); + auto sym = [](const char *s) { return value_t{symbol{s}}; }; + value_t body = make_list({sym("quote"), nested_list(n, sym("x"))}); + value_t expr = make_list({sym("begin"), + make_list({sym("defmacro"), sym("m"), make_list({sym("x")}), body}), + make_list({sym("m"), value_t{int64_t{7}}})}); + stackless_evaluator ev(builtins::make_default_environment()); + value_t r = ev.evaluate(expr); + EXPECT_EQ(list_depth(r), n); + EXPECT_TRUE(values_equal(r, nested_list(n, value_t{int64_t{7}}))); + }); +} + +// ---- snapshot codec -------------------------------------------------------- + +class StateExecDeepDataCodec : public ::testing::Test { +protected: + cvc::app ctx; + + // A node as deep as a process snapshot's would be. + cvc::state &snapshot_node(const std::string &name) { + return ctx.root()("state_exec.processes.p1.snapshot." + name); + } + + // Round-trip `v` through `node`. The state tree written stays shallow, whatever the value's + // depth: cvc::state's own walks (fullName(), childChanged up to the root) recurse once per + // tree level. + void round_trip(cvc::state &node, const value_t &v) { + on_small_stack([&] { + encode_value(node, v); + EXPECT_TRUE(values_equal(v, decode_value(node))); + }); + EXPECT_LE(state_depth(node), 32u); + } +}; + +TEST_F(StateExecDeepDataCodec, ListRoundTrip) { + const std::size_t n = codec_depth(); + value_t v = nested_list(n); + round_trip(snapshot_node("deep_list"), v); + EXPECT_EQ(list_depth(decode_value(snapshot_node("deep_list"))), n); +} + +TEST_F(StateExecDeepDataCodec, DictRoundTrip) { + round_trip(snapshot_node("deep_dict"), nested_dict(codec_depth())); +} + +TEST_F(StateExecDeepDataCodec, MixedRoundTrip) { + round_trip(snapshot_node("deep_mixed"), nested_mixed(codec_depth())); +} + +TEST_F(StateExecDeepDataCodec, ShallowLayoutUnchanged) { + // Values shallower than the spill depth encode exactly as before (no __spill__ table), so + // snapshots of ordinary values are unchanged and old snapshots still decode. + auto &node = ctx.root()("shallow"); + encode_value(node, make_list({make_list({value_t{int64_t{7}}}), make_dict({{"a", value_t{}}})})); + EXPECT_EQ(node("__type__").value(), "list"); + EXPECT_EQ(node("__elems__.000000.__type__").value(), "list"); + EXPECT_EQ(node("__elems__.000000.__elems__.000000.__type__").value(), "int"); + EXPECT_EQ(node("__elems__.000000.__elems__.000000").value(), "7"); + EXPECT_EQ(node("__elems__.000001.__type__").value(), "dict"); + EXPECT_EQ(node("__elems__.000001.__entries__.000000.__key__").value(), "a"); + EXPECT_EQ(node("__elems__.000001.__entries__.000000.__val__.__type__").value(), "nil"); + EXPECT_EQ(node.findDescendant("__spill__"), nullptr); +} + +TEST_F(StateExecDeepDataCodec, CyclicValueThrows) { + auto l = std::make_shared>(); + l->push_back(value_t{int64_t{1}}); + l->push_back(value_t{l}); + EXPECT_THROW(encode_value(ctx.root()("cycle"), value_t{l}), std::runtime_error); + l->clear(); // break the cycle so the list is freed +} + +TEST_F(StateExecDeepDataCodec, SharedValueEncodesTwice) { + // A DAG (the same list reached twice) is not a cycle: it encodes, and decodes as two copies. + value_t inner = make_list({value_t{int64_t{3}}}); + value_t v = make_list({inner, inner}); + auto &node = ctx.root()("dag"); + encode_value(node, v); + EXPECT_TRUE(values_equal(v, decode_value(node))); +} + +TEST_F(StateExecDeepDataCodec, BadSpillReferenceThrows) { + auto &node = ctx.root()("bad_ref"); + encode_value(node, nested_list(200)); + ASSERT_NE(node.findDescendant("__spill__"), nullptr); + std::vector refs; + for (const std::string &path : descendants(node)) { + const std::string suffix = ".__type__"; + if (path.size() > suffix.size() && path.ends_with(suffix) && node(path).value() == "spill_ref") + refs.push_back(&node(path.substr(0, path.size() - suffix.size()))); + } + ASSERT_GE(refs.size(), 2u); + const std::string first = refs[0]->value(); + // Past the end of the table. + refs[0]->value("999999"); + EXPECT_THROW(decode_value(node), std::runtime_error); + // Not a number. + refs[0]->value("x"); + EXPECT_THROW(decode_value(node), std::runtime_error); + // Every reference to entry 0, which then holds a reference to itself: decoding must stop + // at the second use, not expand forever. + std::vector saved; + for (cvc::state *r : refs) { + saved.push_back(r->value()); + r->value("0"); + } + EXPECT_THROW(decode_value(node), std::runtime_error); + for (std::size_t i = 0; i < refs.size(); ++i) + refs[i]->value(saved[i]); + refs[0]->value(first); + EXPECT_TRUE(values_equal(decode_value(node), nested_list(200))); +} + +} // namespace diff --git a/src/cvc/tests/state_exec_intrinsics_test.cpp b/src/cvc/tests/state_exec_intrinsics_test.cpp index cd66db841..574e6e1e8 100644 --- a/src/cvc/tests/state_exec_intrinsics_test.cpp +++ b/src/cvc/tests/state_exec_intrinsics_test.cpp @@ -2,6 +2,7 @@ /// @brief Tests for Phase 5: Intrinsics, Resource Policy, Stdlib. #include +#include #include #include #include @@ -227,12 +228,29 @@ TEST_F(StateTreeIntrinsicsTest, StateDataGetOfSharedDagIsBoundedAndPreservesShar EXPECT_EQ(c0->get(), c1->get()); // sharing preserved -> the copy is a DAG, not a 2^40 tree } -TEST_F(StateTreeIntrinsicsTest, StateDataGetOfTooDeepValueThrows) { +TEST_F(StateTreeIntrinsicsTest, StateDataGetOfDeepValueCopies) { + // The deep copy keeps its work on the heap, so depth has no cap: a value 100000 levels deep + // copies on a 256 KiB stack, into a private copy equal to the original. value_t d{int64_t{0}}; - for (int i = 0; i < 2000; ++i) - d = make_list({d}); // depth 2000 > the deep-copy depth cap + for (int i = 0; i < 100000; ++i) + d = make_list({d}); call("state-data-set", {std::string("deep"), d}); - EXPECT_THROW(call("state-data-get", {std::string("deep")}), std::runtime_error); // stack-safe + value_t got; + std::string error; + boost::thread::attributes small_stack; + small_stack.set_stack_size(256 * 1024); + boost::thread t(small_stack, [&] { + try { + got = call("state-data-get", {std::string("deep")}); + } catch (const std::exception &e) { + error = e.what(); + } + }); + t.join(); + EXPECT_EQ(error, ""); + ASSERT_TRUE(std::holds_alternative(got.v)); + EXPECT_NE(std::get(got.v).get(), std::get(d.v).get()); + EXPECT_TRUE(values_equal(got, d)); } TEST_F(StateTreeIntrinsicsTest, StateSetCoercesScalarsToString) {