xo-umbrella2/xo-object2/utest/flywheel_frame.test.cpp

440 lines
21 KiB
C++

/* @file flywheel_frame.test.cpp
*
* author: Roland Conybeare, Sep 2026
*
* JSON rendering of an AllocFlywheel's state -- one animation frame.
*
* NOT object2's subject. AllocFlywheel is xo-facet's, and the printers are
* xo-printjson's. This lives here because it is the lowest place a flywheel
* can be filled with a REAL representation (DFloat) and then rendered --
* xo-printjson's own utest has facet but no D-type worth putting in a slot.
*
* What is pinned here is the frame's json shape, so that changing it is a
* deliberate act. Nothing outside this repo relies on it (RC, 2026-10-05:
* when MemorySizeInfo moved to the members-style "_members_" format,
* .xo-backlog/xo-printjson/issues/07). Its member names are still asserted
* literally rather than derived from the c++ member names -- a member rename
* must not silently rename a key.
*
* Expectations are OBSERVED, never predicted.
*/
#include <xo/object2/Float.hpp> /* DFloat + IPrintable_DFloat */
#include <xo/object2/SetupObject2.hpp>
#include <xo/object2/number/IGCObject_DFloat.hpp>
#include <xo/facet/ObjectHandle.hpp>
#include <xo/facet/AllocFlywheel.hpp>
#include <xo/printable2/Printable.hpp>
#include <xo/alloc2/arena/IAllocator_DArena.hpp>
#include <xo/facet/cx/FacetAppcx.hpp>
#include <xo/indentlog2/cx/Indentlog2Appcx.hpp>
#include <xo/printjson/PrintJson.hpp>
#include <xo/reflect/Reflect.hpp>
#include <xo/reflectutil/type_name.hpp>
#include <catch2/catch.hpp>
#include <sstream>
#include <regex>
#include <string>
#include <unistd.h> // for ::getpagesize() on osx
namespace xo {
using xo::scm::DFloat;
using xo::scm::SetupObject2;
using xo::json::PrintJson;
using xo::facet::AllocFlywheel;
using xo::facet::DObjectHandle;
using xo::facet::with_facet;
using xo::mm::ArenaConfig;
using xo::mm::ArenaNameStr;
using xo::reflect::Metatype;
using xo::reflect::Reflect;
using xo::mm::AAllocator;
using xo::print::APrintable;
namespace ut {
namespace {
using FrameAppConfig = AppConfig<S_indentlog2_tag, S_facet_tag>;
using FrameAppContext = AppContext<S_indentlog2_tag, S_facet_tag>;
/** a facet context for this file's flywheels.
*
* Local rather than shared: xo-facet's FacetUtestAppcx is not
* installed (it is test-local to that subsystem), so object2's
* tests cannot borrow it.
**/
FacetAppcx & facet_appcx() {
static FrameAppContext s_cx{
FrameAppConfig(Indentlog2Config::make_default(),
FacetConfig::make_default())};
return s_cx.cx<S_facet_tag>();
}
/** flywheel + the registrations a frame needs, once **/
rp<AllocFlywheel> make_fw(const char * tag) {
static bool s_once = []() {
REQUIRE(SetupObject2::register_facets());
SetupObject2::reflect_types();
return true;
}();
(void)s_once;
ArenaConfig storage_cfg{ .name_ = ArenaNameStr::sprintf("%s.storage", tag),
.size_ = 16*1024 };
ArenaConfig strong_cfg { .name_ = ArenaNameStr::sprintf("%s.strong", tag),
.size_ = 4*1024 };
return AllocFlywheel::make_app(facet_appcx(), storage_cfg, strong_cfg);
}
}
TEST_CASE("constructing-a-printjson-reflects-memorysizeinfo",
"[printjson][flywheel]")
{
/* MemorySizeInfo is the one part of a frame still rendered by
* REFLECTION rather than by a bespoke printer -- it is already the
* right shape, so restating its fields would be the duplication
* the Info classes were retired for.
*
* Its registration moved into PrintJson's constructor on
* 2026-09-22, from a free function a caller had to remember. That
* is what this pins: build one, and the description exists.
*/
PrintJson print_json;
auto td = Reflect::require<xo::mm::MemorySizeInfo>();
REQUIRE(td->metatype() == Metatype::mt_struct);
REQUIRE(td->n_child_fixed() == 7);
/* the member names, asserted literally: "name", not "resource_name".
* Members are named explicitly at registration so house style for
* a c++ member cannot leak into the schema.
*
* detail_ is absent by CHOICE -- it points into the stack frame of
* whoever ran the visit. Raw pointers do reflect now
* (.xo-backlog/xo-reflect/issues/01), so 7 rather than 8 is
* curation, not a limitation.
*/
REQUIRE(td->struct_member_name(0) == std::string("name"));
REQUIRE(td->struct_member_name(5) == std::string("lo"));
REQUIRE(td->struct_member_name(6) == std::string("hi"));
} /*TEST_CASE(constructing-a-printjson-reflects-memorysizeinfo)*/
TEST_CASE("empty-flywheel-renders-a-frame", "[printjson][flywheel]")
{
PrintJson print_json;
rp<AllocFlywheel> fw = make_fw("utest.frame.empty");
std::stringstream ss;
print_json.print(*fw.get(), &ss);
/* the whole frame, byte for byte. Brittle on purpose: any change
* to it should require someone to look at what a consumer would
* now receive. 508 bytes for an
* empty flywheel -- worth knowing before choosing a frame rate.
*
* Note "_name_": printjson tags every struct with its c++ type.
* Useful to a consumer as a discriminator, and the reason a frame
* is not as small as its field count suggests.
*/
/* addresses are not reproducible under ASLR, so the frame cannot
* be compared byte-for-byte as it stands. Redact just those, and
* compare the rest exactly -- the shape stays pinned, and a
* changed key or a reordered field still fails.
*/
/* a pool's members are "_members_" entries (members-style,
* .xo-backlog/xo-printjson/issues/07): its value follows its
* "_name_" within one entry, with no nested object between
*/
std::string frame
= std::regex_replace(ss.str(),
std::regex("(\"_name_\": \"(lo|hi)\"[^{}]*\"_value_\": )[0-9]+"),
"$1ADDR");
/* reserved/capacity are not reproducible across HOSTS: arenas
* round up to a VM page, and that page is 4k on linux and 16k on
* darwin (getpagesize()). Observed, both for this flywheel's
* 16k storage + 4k strong config:
*
* requested 4k pages 16k pages
* storage 16384 16384 16384
* strong 4096 8192 16384
* strong-free -- 4096 16384
* capacity -- 511 1023
*
* At 4k the strong pool takes two pages rather than one, because
* DArenaVector::map inflates the request by the arena overhead
* and that pushes 4096 past a page boundary; at 16k the same
* request fits in one. capacity follows the strong pool's
* reservation (and is one short of a round number because the
* per-allocation overhead costs a slot).
*
* None of that is the shape, so redact the VALUES and keep the
* KEYS. Note [0-9]+ still carries real coverage: a
* value that is not an integer does not match, so the
* substitution does not fire and the comparison below fails --
* which is how a missing json printer for size_t showed up on
* darwin, rendering these as <error-json-printer-not-found>.
* The magnitudes are asserted structurally after the frame.
*/
frame = std::regex_replace(frame,
std::regex("(\"_name_\": \"reserved\"[^{}]*\"_value_\": )[0-9]+"),
"$1INT");
frame = std::regex_replace(frame,
std::regex("\"capacity\": [0-9]+"),
"\"capacity\": INT");
INFO("frame: " << frame);
/* "_canonical_type_", "_short_type_": each object's canonical
* name (the key into a type -> source map,
* .xo-backlog/xo-websock/issues/12) and short name -- built, not
* spelled out, so a spelling difference between compilers cannot
* fail this
*/
auto type_member = [](std::string_view name) {
return ", \"_canonical_type_\": \"" + std::string(name) + "\""
+ ", \"_short_type_\": \""
+ xo::reflect::TypeDescrBase::make_short_name(name) + "\"";
};
std::string const pool_type
= type_member(xo::reflect::type_name<xo::mm::MemorySizeInfo>());
/* one pool, members-style (.xo-backlog/xo-printjson/issues/07):
* each member a "_members_" entry, under its declared type
*/
using xo::mm::MemorySizeInfo;
auto entry = [&type_member](char const * name, std::string_view type,
char const * metatype, std::string const & value) {
return "{\"_name_\": \"" + std::string(name) + "\"" + type_member(type)
+ ", \"_metatype_\": \"" + metatype + "\", \"_value_\": " + value + "}";
};
auto pool = [&](char const * name) {
auto const size_t_name = xo::reflect::type_name<decltype(MemorySizeInfo::used_)>();
auto const ptr_name = xo::reflect::type_name<decltype(MemorySizeInfo::lo_)>();
return "{\"_name_\": \"MemorySizeInfo\"" + pool_type + ", \"_members_\": ["
+ entry("name", xo::reflect::type_name<decltype(MemorySizeInfo::resource_name_)>(),
"atomic", "\"" + std::string(name) + "\"") + ", "
+ entry("used", size_t_name, "atomic", "0") + ", "
+ entry("allocated", size_t_name, "atomic", "0") + ", "
+ entry("committed", size_t_name, "atomic", "0") + ", "
+ entry("reserved", size_t_name, "atomic", "INT") + ", "
+ entry("lo", ptr_name, "pointer", "ADDR") + ", "
+ entry("hi", ptr_name, "pointer", "ADDR") + "]}";
};
REQUIRE(frame ==
"{\"_name_\": \"Flywheel\"" + type_member(xo::reflect::type_name<AllocFlywheel>())
/* objects print once, numbered: the frame 1, its root set 2.
* The pools are made on the fly by visit_pools, so have no
* lasting identity -- no _id_ (.xo-backlog/xo-printjson/issues/02)
*/
+ ", \"_id_\": 1"
+ ", \"pools\": ["
+ pool("utest.frame.empty.storage") + ", "
+ pool("utest.frame.empty.strong") + ", "
+ pool("utest.frame.empty.strong-free") + "]"
/* "RootSet", not "RootSetInfo", and "Flywheel" above rather
* than "FlywheelInfo": neither struct exists any more. Both
* are now bespoke printers reading the live flywheel. Every
* other key and value here was unchanged by that, deliberately
*/
", \"strong\": {\"_name_\": \"RootSet\""
+ type_member(xo::reflect::type_name<xo::facet::DHandleArena<xo::facet::ObjectSlot>>())
+ ", \"_id_\": 2"
+ ", \"size\": 0, \"capacity\": INT, \"live\": 0"
", \"free\": [], \"slots\": []}}");
/* the bounds the redaction hid, asserted structurally instead.
* Read through visit_pools, which is the path the printer takes
*/
const std::size_t page_z = ::getpagesize();
fw->visit_pools([page_z](const xo::mm::MemorySizeInfo & pool) {
REQUIRE(static_cast<const char *>(pool.hi_)
- static_cast<const char *>(pool.lo_)
== static_cast<long>(pool.reserved_));
/* what "reserved": INT gave up: every pool is a whole,
* non-empty number of VM pages. That is the property the
* literal was really pinning -- 8192 and 4096 were just
* what it looks like where a page is 4k
*/
REQUIRE(pool.reserved_ > 0);
REQUIRE(pool.reserved_ % page_z == 0);
});
/* what "capacity": INT gave up: not the number, but that the
* printer reports the LIVE one. Checked against the unredacted
* text, so a printer that emitted a constant would still fail
*/
REQUIRE(ss.str().find("\"capacity\": "
+ std::to_string(fw->strong_capacity()))
!= std::string::npos);
REQUIRE(fw->strong_capacity() > 0);
} /*TEST_CASE(empty-flywheel-renders-a-frame)*/
TEST_CASE("occupied-slots-appear-in-the-frame", "[printjson][flywheel]")
{
using HFloat = DObjectHandle<APrintable, DFloat>;
PrintJson print_json;
rp<AllocFlywheel> fw = make_fw("utest.frame.live");
auto alloc = with_facet<AAllocator>::mkobj(&fw->storage());
auto h0 = HFloat::make_strong_ref
(fw, with_facet<APrintable>::mkobj(DFloat::_box(alloc, 1.5)));
auto h1 = HFloat::make_strong_ref
(fw, with_facet<APrintable>::mkobj(DFloat::_box(alloc, 2.5)));
std::stringstream ss;
print_json.print(*fw.get(), &ss);
const std::string frame = ss.str();
INFO("frame: " << frame);
/* substrings, not the whole frame: addresses and `used' are not
* reproducible, so pinning them would make this a test of the
* allocator's layout rather than of the schema
*/
REQUIRE(frame.find("\"live\": 2") != std::string::npos);
REQUIRE(frame.find("\"type\": \"xo::scm::DFloat\"") != std::string::npos);
/* The first allocation sits at 16, not 0: an arena's storage now
* begins behind a preamble (the DArena back pointer) AND each
* allocation carries an 8-byte AllocHeader, which DHandleStore
* requires on its storage arena (see its ctor). Pinned because
* the number is a consequence of both -- if this reads 0 again,
* headers were silently turned off and alloc_info() became a
* segfault waiting to happen.
*/
REQUIRE(frame.find("\"offset\": 16") != std::string::npos);
/* size is the allocation's own, read from its alloc header --
* AllocInfo::size(), which the store-only ObjectSlot contract
* makes safe to call (.xo-backlog/xo-facet/issues/04). Spelled
* with the enclosing "offset" so it cannot accidentally match
* RootSet's unrelated "size" key one level up.
*
* 8 here, which happens to equal sizeof(DFloat) -- a fixed-size
* representation needing no padding. It is NOT read from the
* type: DArray and DString fix capacity at construction rather
* than in the type, so for those the two diverge and only the
* header is right. Observed (a first draft predicted 16, for the
* allocation including its header; the header is excluded).
*/
REQUIRE(frame.find("\"offset\": 16, \"size\": 8") != std::string::npos);
/* no "ix" any more: every slot is emitted, so a consumer reads
* an index from array position. SlotInfo carried one until
* 2026-09-20.
*/
REQUIRE(frame.find("\"ix\":") == std::string::npos);
/* the slots are ObjectSlot, so each carries its own _name_ */
REQUIRE(frame.find("\"_name_\": \"ObjectSlot\"") != std::string::npos);
/* offset is the frame's whole reason for existing -- it is how a
* consumer follows one object across frames -- and it is resolved
* per slot from its own pointer, so two slots must differ
*/
std::vector<xo::facet::ObjectSlot> slot_v;
fw->visit_object_slots([&slot_v](const xo::facet::ObjectSlot & slot) {
slot_v.push_back(slot);
});
REQUIRE(slot_v.size() == 2);
REQUIRE(slot_v[0].opaque_data() != slot_v[1].opaque_data());
REQUIRE(h0.object_ix() != h1.object_ix());
} /*TEST_CASE(occupied-slots-appear-in-the-frame)*/
TEST_CASE("released-slot-moves-to-the-free-list", "[printjson][flywheel]")
{
using HFloat = DObjectHandle<APrintable, DFloat>;
PrintJson print_json;
rp<AllocFlywheel> fw = make_fw("utest.frame.free");
auto alloc = with_facet<AAllocator>::mkobj(&fw->storage());
{
auto h = HFloat::make_strong_ref
(fw, with_facet<APrintable>::mkobj(DFloat::_box(alloc, 1.5)));
REQUIRE(h.object_ix() == 0);
}
std::stringstream ss;
print_json.print(*fw.get(), &ss);
const std::string frame = ss.str();
INFO("frame: " << frame);
/* the slot EMPTIES rather than disappearing: it stays at its
* position and renders as null, its index is on `free', and `size'
* stays 1 because it is a high-water mark. A consumer animating
* the root set draws a slot going dark, which it could not do if
* the entry vanished and shifted everything after it.
*
* This is what retiring SlotInfo bought: an ObjectSlot can BE
* empty, where a shadow struct had to be omitted.
*/
REQUIRE(frame.find("\"size\": 1") != std::string::npos);
REQUIRE(frame.find("\"live\": 0") != std::string::npos);
REQUIRE(frame.find("\"free\": [0]") != std::string::npos);
REQUIRE(frame.find("\"slots\": [null]") != std::string::npos);
} /*TEST_CASE(released-slot-moves-to-the-free-list)*/
TEST_CASE("free-list-order-is-reported-not-derived", "[printjson][flywheel]")
{
/* `free' is the one part of the frame that visit_object_slots
* cannot supply. The SET of free indices is derivable -- a
* released slot renders as null -- but the ORDER is not, and the
* order is what says which slot comes back next. That is why
* DHandleStore::visit_free_list exists alongside
* visit_object_slots rather than the printer inferring it.
*
* Two releases, so an order exists to get wrong.
*/
using HFloat = DObjectHandle<APrintable, DFloat>;
PrintJson print_json;
rp<AllocFlywheel> fw = make_fw("utest.frame.order");
auto alloc = with_facet<AAllocator>::mkobj(&fw->storage());
{
auto h0 = HFloat::make_strong_ref
(fw, with_facet<APrintable>::mkobj(DFloat::_box(alloc, 1.5)));
auto h1 = HFloat::make_strong_ref
(fw, with_facet<APrintable>::mkobj(DFloat::_box(alloc, 2.5)));
REQUIRE(h0.object_ix() == 0);
REQUIRE(h1.object_ix() == 1);
}
/* both released at scope exit, in REVERSE declaration order -- so
* index 1 is released first and index 0 last
*/
std::stringstream ss;
print_json.print(*fw.get(), &ss);
const std::string frame = ss.str();
INFO("frame: " << frame);
/* push order: 1 was released first. Observed, and it is not the
* order a reader would guess -- which is the point
*/
REQUIRE(frame.find("\"free\": [1, 0]") != std::string::npos);
REQUIRE(frame.find("\"slots\": [null, null]") != std::string::npos);
/* and the LAST entry is the one that comes back -- so a consumer
* reading `free' left-to-right is reading it backwards
*/
auto h2 = HFloat::make_strong_ref
(fw, with_facet<APrintable>::mkobj(DFloat::_box(alloc, 3.5)));
REQUIRE(h2.object_ix() == 0);
} /*TEST_CASE(free-list-order-is-reported-not-derived)*/
} /*namespace ut*/
} /*namespace xo*/
/* end flywheel_frame.test.cpp */