Heap render debugging facility (#377)

It's occasionally useful while debugging to be able to look at the heap's layout and internal state machine.

Co-authored-by: Nathaniel Wesley Filardo <nfilardo@microsoft.com>
Co-authored-by: Robert Norton-Wright <robert.norton@scisemi.com>
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 1e29fe1..833a291 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -17,7 +17,7 @@
         include:
           - sonata: false
           - build-type: debug
-            build-flags: --debug-loader=y --debug-scheduler=y --debug-allocator=y -m debug
+            build-flags: --debug-loader=y --debug-scheduler=y --debug-allocator=y --allocator-rendering=y -m debug
           - build-type: release
             build-flags: --debug-loader=n --debug-scheduler=n --debug-allocator=n -m release --stack-usage-check-allocator=y --stack-usage-check-scheduler=y
           - board: sonata-simulator
diff --git a/sdk/core/allocator/alloc.h b/sdk/core/allocator/alloc.h
index 9560be0..85f8c6e 100644
--- a/sdk/core/allocator/alloc.h
+++ b/sdk/core/allocator/alloc.h
@@ -2811,4 +2811,163 @@
 	{
 		ABORT();
 	}
+
+#if HEAP_RENDER
+	public:
+	/**
+	 * "Render" the heap for debugging.
+	 */
+	template<bool Asserts = true, bool Chatty = true>
+	void render()
+	{
+		using RenderDebug = ConditionalDebug<Chatty, "Allocator heap">;
+
+		size_t measuredAllocated = 0, measuredFree = 0, measuredQuarantined = 0;
+
+		auto toAddr = [](void *p) {
+			return static_cast<ptraddr_t>(CHERI::Capability{p}.address());
+		};
+
+		auto header =
+		  static_cast<MChunkHeader *>(static_cast<void *>(heapStart));
+
+		RenderDebug::log("Dumping MState={} start={} end={}",
+		                 toAddr(this),
+		                 toAddr(heapStart),
+		                 heapStart.top());
+
+		while (toAddr(header) != heapStart.top())
+		{
+			RenderDebug::log("  header {}: size={} inuse={} pinuse={}",
+			                 toAddr(header),
+			                 header->size_get(),
+			                 header->isCurrInUse,
+			                 header->isPrevInUse);
+
+			if (!header->is_in_use())
+			{
+				measuredFree += header->size_get();
+
+				auto chunk = MChunk::from_header(header);
+				if (!ds::linked_list::is_singleton(&chunk->ring))
+				{
+					RenderDebug::log(
+					  "   free ring <{},{}>",
+					  toAddr(MChunk::from_ring(chunk->ring.cell_prev())),
+					  toAddr(MChunk::from_ring(chunk->ring.cell_next())));
+				}
+				else
+				{
+					RenderDebug::log("   free ring empty");
+				}
+
+				if (!is_small(header->size_get()))
+				{
+					auto t = TChunk::from_mchunk(chunk);
+
+					if (t->is_tree_ring())
+					{
+						RenderDebug::log("   tree ring, index={}", t->index);
+					}
+					else
+					{
+						RenderDebug::log(
+						  "   tree, index={} parent={} children=[{},{}]",
+						  t->index,
+						  toAddr(t->parent),
+						  toAddr(t->child[0]),
+						  toAddr(t->child[1]));
+					}
+				}
+			}
+			else
+			{
+				measuredAllocated += header->size_get();
+			}
+
+			header = header->cell_next();
+		}
+
+		auto showQuarantineRing = [&](ChunkFreeLink *&p) {
+			auto header = MChunkHeader::from_body(MChunk::from_ring(p));
+			RenderDebug::log(
+			  "   quarantined {} size={}", toAddr(header), header->size_get());
+			measuredQuarantined += header->size_get();
+			if constexpr (Asserts)
+			{
+				RenderDebug::invariant(ds::linked_list::is_well_formed(p),
+				                       "Quarantine list node malformed");
+			}
+			return false;
+		};
+
+		{
+			decltype(quarantinePendingRing)::Ix head = 0, tail = 0;
+
+			quarantinePendingRing.head_get(head);
+			quarantinePendingRing.tail_get(tail);
+			RenderDebug::log(" Quarantine status: empty={} head={} tail={}",
+			                 quarantinePendingRing.is_empty(),
+			                 head,
+			                 tail);
+		}
+
+		if (quarantineFinishedSentinel.is_empty())
+		{
+			RenderDebug::log("  finished ring empty");
+		}
+		else
+		{
+			RenderDebug::log("  finished ring <{},{}>:",
+			                 toAddr(quarantineFinishedSentinel.last()),
+			                 toAddr(quarantineFinishedSentinel.first()));
+			quarantine_finished_get()->search(showQuarantineRing);
+		}
+
+		for (size_t ix = 0; ix < QuarantineRings; ix++)
+		{
+			if (quarantinePendingChunks[ix].is_empty())
+			{
+				RenderDebug::log(
+				  "  index={} epoch={} empty", ix, quarantinePendingEpoch[ix]);
+			}
+			else
+			{
+				RenderDebug::log(
+				  "  index={} epoch={}:", ix, quarantinePendingEpoch[ix]);
+				quarantine_pending_get(ix)->search(showQuarantineRing);
+			}
+		}
+
+		auto measuredTotal = measuredAllocated + measuredFree;
+
+		RenderDebug::log(
+		  "Sizes: alloc={} free={} (expect {}) quar={} (expect {}) "
+		  "total={} (expect {})",
+		  measuredAllocated,
+		  measuredFree,
+		  heapFreeSize,
+		  measuredQuarantined,
+		  heapQuarantineSize,
+		  measuredTotal,
+		  heapTotalSize);
+
+		if constexpr (Asserts)
+		{
+			RenderDebug::invariant(measuredFree == heapFreeSize,
+			                       "Bad accounting in free size: {} {}",
+			                       std::make_tuple(measuredFree, heapFreeSize));
+
+			RenderDebug::invariant(
+			  measuredQuarantined == heapQuarantineSize,
+			  "Bad accounting in quarantine size: {} {}",
+			  std::make_tuple(measuredQuarantined, heapQuarantineSize));
+
+			RenderDebug::invariant(
+			  measuredTotal == heapTotalSize,
+			  "Bad accounting in total size: {} {}",
+			  std::make_tuple(measuredTotal, heapTotalSize));
+		}
+	}
+#endif
 };
diff --git a/sdk/core/allocator/main.cc b/sdk/core/allocator/main.cc
index bb21962..8db5936 100644
--- a/sdk/core/allocator/main.cc
+++ b/sdk/core/allocator/main.cc
@@ -1268,3 +1268,11 @@
 {
 	return gm->heapFreeSize;
 }
+
+[[cheri::interrupt_state(disabled)]] int heap_render()
+{
+#if HEAP_RENDER
+	gm->render();
+#endif
+	return 0;
+}
diff --git a/sdk/include/debug.hh b/sdk/include/debug.hh
index af0732e..9cfaa56 100644
--- a/sdk/include/debug.hh
+++ b/sdk/include/debug.hh
@@ -715,6 +715,27 @@
 		Invariant(T, const char *, Ts &&...) -> Invariant<Ts...>;
 
 		/**
+		 * Overt wrapper function around Invariant.  Sometimes template
+		 * deduction guides just don't cut it.  At the cost of a
+		 * std::make_tuple at the call site, we can still take advantage
+		 * of much of the machinery here.
+		 */
+		template<typename T, typename... Args>
+		__always_inline static void
+		invariant(T                   condition,
+		          const char         *fmt,
+		          std::tuple<Args...> args = std::make_tuple(),
+		          SourceLocation      loc  = SourceLocation::current())
+		{
+			std::apply(
+			  [&](Args... iargs) {
+				  Invariant<Args...>(
+				    condition, fmt, std::forward<Args>(iargs)..., loc);
+			  },
+			  args);
+		}
+
+		/**
 		 * Deduction guide, allows `Assert` to be used as if it were a
 		 * function.
 		 */
@@ -727,6 +748,27 @@
 		 */
 		template<typename... Ts>
 		Assert(auto, const char *, Ts &&...) -> Assert<Ts...>;
+
+		/**
+		 * Overt wrapper function around Assert.  Sometimes template
+		 * deduction guides just don't cut it.  At the cost of a
+		 * std::make_tuple at the call site, we can still take advantage
+		 * of much of the machinery here.
+		 */
+		template<typename T, typename... Args>
+		__always_inline static void
+		assertion(T                   condition,
+		          const char         *fmt,
+		          std::tuple<Args...> args = std::make_tuple(),
+		          SourceLocation      loc  = SourceLocation::current())
+		{
+			std::apply(
+			  [&](Args... iargs) {
+				  Assert<Args...>(
+				    condition, fmt, std::forward<Args>(iargs)..., loc);
+			  },
+			  args);
+		}
 	};
 
 	enum class StackCheckMode
diff --git a/sdk/include/stdlib.h b/sdk/include/stdlib.h
index 54a9dc6..2dca318 100644
--- a/sdk/include/stdlib.h
+++ b/sdk/include/stdlib.h
@@ -307,6 +307,15 @@
 	return (address >= heap_start) && (address < heap_end);
 }
 
+/**
+ * Dump a textual rendering of the heap's structure to the debug console.
+ *
+ * If the RTOS is not built with --allocator-rendering=y, this is a no-op.
+ *
+ * Returns zero on success, non-zero on error (e.g. compartment call failure).
+ */
+int __cheri_compartment("alloc") heap_render();
+
 static inline void __dead2 abort()
 {
 	panic();
diff --git a/sdk/xmake.lua b/sdk/xmake.lua
index 07a37c5..ae74917 100644
--- a/sdk/xmake.lua
+++ b/sdk/xmake.lua
@@ -25,6 +25,11 @@
 	set_description("Track per-thread cycle counts in the scheduler");
 	set_showmenu(true)
 
+option("allocator-rendering")
+	set_default(false)
+	set_description("Include heap_render() functionality in the allocator")
+	set_showmenu(true)
+
 function debugOption(name)
 	option("debug-" .. name)
 		set_default(false)
@@ -229,6 +234,7 @@
 	on_load(function (target)
 		target:set("cheriot.compartment", "alloc")
 		target:set('cheriot.debug-name', "allocator")
+		target:add('defines', "HEAP_RENDER=" .. tostring(get_config("allocator-rendering")))
 	end)
 
 target("cheriot.token_library")
diff --git a/tests/allocator-test.cc b/tests/allocator-test.cc
index 05a4ac8..bbce8d3 100644
--- a/tests/allocator-test.cc
+++ b/tests/allocator-test.cc
@@ -154,6 +154,8 @@
 			}
 		}
 		TEST(memoryExhausted, "Failed to exhaust memory");
+		debug_log("Calling heap_render");
+		TEST_EQUAL(heap_render(), 0, "heap_render returned non-zero");
 		debug_log("Trying a non-blocking allocation");
 		// nullptr check because we explicitly want to check for OOM
 		TEST(heap_allocate(&noWait, MALLOC_CAPABILITY, BigAllocSize) == nullptr,