Four bugs found live verifying the 8 identity thumbdrives (FABRIC-3.md §IX)
All found by actually running the identity workflow §VII/§VIII made possible, not by code review: 1. Zuse/WIREBIND cross-contamination on detach: capsule_zuse_boot_logout() and capsule_wirebind_unclean_detach() both had no device parameter, so an unrelated device detaching (while the real owner's own stayed attached) incorrectly tore down the wrong session. Both now compare the departing device against their own tracked one, mirroring capsule_wirebind.c's pre-existing g_wirebind_attached_dev precedent. 2. Dictionary-entry memory leak: vm_create_word()'s sf_malloc()'d DictEntry (plus a second per-entry allocation for transition_metrics) was never freed by vm_cleanup(), in both the hosted and kernel implementations. Caused a real kernel PANIC after 8-9 repeated VM birth/kill cycles in one boot. Fixed by walking vm->latest in both. 3. sf_malloc/sf_free (alloc_kernel.c) was a 4MB bump arena with a deliberate no-op free, sized on "VM born once, never killed" -- fix #2 alone didn't stop the panic because free() itself discarded the pointer regardless. Given a real free list (first-fit reuse). 4. Headless-console gate didn't re-engage after a mid-boot logout: the original fix (sk_console_mark_login(), one-way sticky) only gated the first login of the boot. Replaced with a live check (sk_console_identity_present()) re-evaluated continuously, including inside sk_console_readline()'s own blocking idle loop -- the console is normally sitting blocked there when a hot-unplug logout happens, so checking only at the top of the REPL loop wasn't enough. Also: MINT now verifies its own write (verify_mint(), capsule_mint.c) by reading back through the same check a real attach performs, rather than trusting blkio_write()'s BLK_OK alone -- logged via log_message(), not console_println(), per direct instruction. Verified live, amd64: the full 8-identity repeated attach/detach cycle that previously panicked at the same point every time now completes clean, and a full serial-log sweep found zero bare unauthenticated prompts anywhere in the run. Three-arch clean-qemu acceptance passed. Still open, not fixed here: a 3+-simultaneous-device USB enumeration failure found in a separate live test, not yet root-caused. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018EjXFo7mPXjUMjfJeuUUz4
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0bae928aad
commit
2c1b3cd695
@@ -23,17 +23,37 @@
|
||||
/*
|
||||
* platform/alloc_kernel.c - Kernel (bare-metal) memory allocator
|
||||
*
|
||||
* Static arena with bump allocation. Free is a no-op.
|
||||
* This is appropriate for kernel use where:
|
||||
* - VM is long-lived (no restart)
|
||||
* - Allocations happen at init time
|
||||
* - Runtime allocations are rare
|
||||
* Static arena, bump-allocated with a real free list on top.
|
||||
*
|
||||
* FABRIC-3.md §VII follow-on, 2026-09-06: this used to bump-allocate only,
|
||||
* with sf_free() a deliberate no-op -- "VM is long-lived (no restart),
|
||||
* allocations happen at init time, runtime allocations are rare... no
|
||||
* fragmentation issues in practice." That premise held until this
|
||||
* session's own repeated identity-verification workflow (WIREBIND
|
||||
* birth/kill cycles, one console+user VM pair per identity, all sharing
|
||||
* this one global arena) needed VMs born and killed repeatedly within a
|
||||
* single boot -- confirmed live: a real kernel PANIC ("malloc failed"
|
||||
* cascading into "Stadium: eviction... governor invariant broken", full
|
||||
* halt) at the exact same cycle count on every run, because freed
|
||||
* dictionary entries (vm_cleanup() was itself also missing this free
|
||||
* before an earlier pass of this same fix) were never actually reclaimed
|
||||
* -- sf_free() threw them away regardless.
|
||||
*
|
||||
* Every allocation now carries a small header (size + free-list link) so
|
||||
* a freed block can be pushed onto g_free_list and reused by a
|
||||
* later sf_malloc() of equal or smaller size (first-fit, no splitting --
|
||||
* deliberately simple: this workload's repeated allocations are for the
|
||||
* same capsules loaded into a fresh VM each time, so freed blocks from a
|
||||
* just-killed VM's dictionary are typically an exact or near-exact fit
|
||||
* for the next VM's own). Falls back to bump-allocating a fresh block
|
||||
* from the arena when no free block is large enough, exactly as before.
|
||||
*
|
||||
* Arena size: 4MB by default (configurable via SF_ARENA_SIZE)
|
||||
*/
|
||||
|
||||
#include "platform_alloc.h"
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifndef SF_ARENA_SIZE
|
||||
#define SF_ARENA_SIZE (4 * 1024 * 1024) /* 4MB default */
|
||||
@@ -43,10 +63,22 @@
|
||||
#define SF_ALIGN 8
|
||||
#define SF_ALIGN_UP(x) (((x) + (SF_ALIGN - 1)) & ~(SF_ALIGN - 1))
|
||||
|
||||
/* Per-allocation header, immediately before the pointer sf_malloc()
|
||||
* returns. `next` is meaningful only while the block is on the free
|
||||
* list -- it's live/garbage data for the caller otherwise, matching the
|
||||
* classic free-list-node-in-freed-space technique, just kept as a fixed
|
||||
* header field instead of reusing payload bytes so there's no minimum-
|
||||
* payload-size constraint to worry about. */
|
||||
typedef struct sf_block_header {
|
||||
size_t size; /* payload size, in bytes, SF_ALIGN_UP'd */
|
||||
struct sf_block_header *next; /* free-list link; valid only while free */
|
||||
} sf_block_header_t;
|
||||
|
||||
/* Static arena */
|
||||
static uint8_t g_arena[SF_ARENA_SIZE] __attribute__((aligned(SF_ALIGN)));
|
||||
static size_t g_arena_offset = 0;
|
||||
static int g_initialized = 0;
|
||||
static sf_block_header_t *g_free_list = (sf_block_header_t *)0;
|
||||
|
||||
/* Statistics */
|
||||
static sf_alloc_stats_t g_stats = {0};
|
||||
@@ -64,6 +96,7 @@ static sf_alloc_stats_t g_stats = {0};
|
||||
int sf_alloc_init(void)
|
||||
{
|
||||
g_arena_offset = 0;
|
||||
g_free_list = (sf_block_header_t *)0;
|
||||
g_initialized = 1;
|
||||
|
||||
g_stats.total_bytes = SF_ARENA_SIZE;
|
||||
@@ -76,27 +109,55 @@ int sf_alloc_init(void)
|
||||
}
|
||||
|
||||
/*
|
||||
* @brief Allocate memory from the static kernel arena.
|
||||
* @brief Allocate memory from the static kernel arena, reusing a freed
|
||||
* block first if one is large enough.
|
||||
*
|
||||
* Bump-allocates @p size bytes from @c g_arena, rounding up to @c SF_ALIGN
|
||||
* (8 bytes) to preserve alignment for 64-bit values. Lazily calls
|
||||
* @c sf_alloc_init() on the first invocation if the arena has not been
|
||||
* Rounds @p size up to @c SF_ALIGN (8 bytes). First searches @c
|
||||
* g_free_list for the first block whose payload is >= the requested size
|
||||
* (first-fit, no splitting -- see this file's own top-of-file doc comment
|
||||
* for why that's the right tradeoff for this workload) and reuses it
|
||||
* whole if found. Otherwise bump-allocates a fresh header+payload block
|
||||
* from @c g_arena, exactly as before this fix. Lazily calls @c
|
||||
* sf_alloc_init() on the first invocation if the arena has not been
|
||||
* explicitly initialised. Returns @c NULL for zero-size requests and when
|
||||
* the arena is exhausted.
|
||||
*
|
||||
* @note Because this is a bump allocator there is no reclaim path; once the
|
||||
* arena is full it stays full until the kernel is reset.
|
||||
* neither a free block nor remaining arena space can satisfy the request.
|
||||
*
|
||||
* @param size Number of bytes to allocate.
|
||||
* @return Pointer to the allocated block on success, @c NULL on failure.
|
||||
* @return Pointer to the allocated block's payload on success, @c NULL on
|
||||
* failure.
|
||||
*/
|
||||
void* sf_malloc(size_t size)
|
||||
{
|
||||
if (!g_initialized) sf_alloc_init();
|
||||
if (size == 0) return (void*)0;
|
||||
|
||||
size_t aligned_size = SF_ALIGN_UP(size);
|
||||
size_t new_offset = g_arena_offset + aligned_size;
|
||||
size_t payload = SF_ALIGN_UP(size);
|
||||
|
||||
/* First-fit scan of the free list. */
|
||||
sf_block_header_t **pp = &g_free_list;
|
||||
while (*pp)
|
||||
{
|
||||
if ((*pp)->size >= payload)
|
||||
{
|
||||
sf_block_header_t *blk = *pp;
|
||||
*pp = blk->next;
|
||||
blk->next = (sf_block_header_t *)0;
|
||||
|
||||
g_stats.used_bytes += blk->size;
|
||||
g_stats.alloc_count++;
|
||||
if (g_stats.used_bytes > g_stats.peak_bytes)
|
||||
{
|
||||
g_stats.peak_bytes = g_stats.used_bytes;
|
||||
}
|
||||
return (void *)(blk + 1);
|
||||
}
|
||||
pp = &(*pp)->next;
|
||||
}
|
||||
|
||||
/* No free block large enough -- bump-allocate a fresh one. */
|
||||
size_t header_size = SF_ALIGN_UP(sizeof(sf_block_header_t));
|
||||
size_t total = header_size + payload;
|
||||
size_t new_offset = g_arena_offset + total;
|
||||
|
||||
if (new_offset > SF_ARENA_SIZE)
|
||||
{
|
||||
@@ -104,17 +165,19 @@ void* sf_malloc(size_t size)
|
||||
return (void*)0;
|
||||
}
|
||||
|
||||
void* ptr = &g_arena[g_arena_offset];
|
||||
sf_block_header_t *blk = (sf_block_header_t *)&g_arena[g_arena_offset];
|
||||
g_arena_offset = new_offset;
|
||||
blk->size = payload;
|
||||
blk->next = (sf_block_header_t *)0;
|
||||
|
||||
g_stats.used_bytes = g_arena_offset;
|
||||
g_stats.used_bytes += payload;
|
||||
g_stats.alloc_count++;
|
||||
if (g_stats.used_bytes > g_stats.peak_bytes)
|
||||
{
|
||||
g_stats.peak_bytes = g_stats.used_bytes;
|
||||
}
|
||||
|
||||
return ptr;
|
||||
return (void *)(blk + 1);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -195,35 +258,33 @@ void* sf_realloc(void* ptr, size_t new_size)
|
||||
}
|
||||
|
||||
/*
|
||||
* @brief Release a kernel arena allocation (no-op).
|
||||
* @brief Release a kernel arena allocation, making it available for reuse.
|
||||
*
|
||||
* The kernel bump allocator has no reclaim mechanism — once bytes are
|
||||
* allocated from @c g_arena they remain consumed until the kernel resets.
|
||||
* This function exists solely to satisfy the @c sf_free() contract expected
|
||||
* by shared VM code, and to keep @c g_stats.free_count accurate for
|
||||
* diagnostic purposes.
|
||||
*
|
||||
* Callers must not assume that freed memory is reclaimed or reusable.
|
||||
* FABRIC-3.md §VII follow-on, 2026-09-06: this used to be a documented
|
||||
* no-op (see this file's own top-of-file doc comment for why that
|
||||
* stopped being acceptable). Pushes the block's header onto @c
|
||||
* g_free_list, where a future @c sf_malloc() of equal or smaller size
|
||||
* will find and reuse it -- no coalescing with neighboring free blocks,
|
||||
* matching the same simplicity tradeoff @c sf_malloc()'s first-fit search
|
||||
* makes.
|
||||
*
|
||||
* @param ptr Pointer previously returned by @c sf_malloc() / @c sf_calloc()
|
||||
* (may be @c NULL; silently ignored).
|
||||
* (may be @c NULL; silently ignored). Must not be used again
|
||||
* by the caller after this call, and must not be freed twice.
|
||||
*/
|
||||
void sf_free(void* ptr)
|
||||
{
|
||||
/* Bump allocator: free is a no-op.
|
||||
*
|
||||
* This is acceptable because:
|
||||
* 1. VM allocations happen at init time
|
||||
* 2. VM runs until power-off
|
||||
* 3. No fragmentation issues in practice
|
||||
*
|
||||
* If needed, could implement a simple free list here.
|
||||
*/
|
||||
if (ptr)
|
||||
if (!ptr) return;
|
||||
|
||||
sf_block_header_t *blk = ((sf_block_header_t *)ptr) - 1;
|
||||
blk->next = g_free_list;
|
||||
g_free_list = blk;
|
||||
|
||||
if (g_stats.used_bytes >= blk->size)
|
||||
{
|
||||
g_stats.free_count++;
|
||||
g_stats.used_bytes -= blk->size;
|
||||
}
|
||||
(void)ptr;
|
||||
g_stats.free_count++;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -321,6 +321,35 @@ void vm_cleanup(VM* vm)
|
||||
vm->ssm_config = NULL;
|
||||
}
|
||||
|
||||
/* FABRIC-3.md §VII follow-on, 2026-09-06: every DictEntry is its own
|
||||
* sf_malloc() (vm_create_word(), dictionary_management.c) -- separate
|
||||
* from vm->memory entirely, so freeing that arena below never touched
|
||||
* them. Neither did anything else in this function, or anywhere else
|
||||
* in the codebase (confirmed by grep before writing this fix) -- every
|
||||
* word a VM ever defined leaked permanently on kill. Never noticed
|
||||
* before: the hosted binary normally only calls this once at process
|
||||
* exit (the OS reclaims everything anyway), and kernel VMs were
|
||||
* normally born once and kept alive for a whole boot, not repeatedly
|
||||
* born and killed -- confirmed live as a real PANIC ("malloc failed"
|
||||
* cascading into "Stadium: eviction... governor invariant broken",
|
||||
* full halt) during this session's own repeated identity-verification
|
||||
* workflow (8 consecutive WIREBIND birth/kill cycles in one boot).
|
||||
* transition_metrics (vm_create_word()'s own second, per-entry
|
||||
* sf_malloc()) must be freed too, before the entry itself -- freeing
|
||||
* entry first would leave no way to reach it. */
|
||||
{
|
||||
DictEntry *dict_entry = vm->latest;
|
||||
while (dict_entry) {
|
||||
DictEntry *next_entry = dict_entry->link;
|
||||
if (dict_entry->transition_metrics) {
|
||||
sf_free(dict_entry->transition_metrics);
|
||||
}
|
||||
sf_free(dict_entry);
|
||||
dict_entry = next_entry;
|
||||
}
|
||||
vm->latest = NULL;
|
||||
}
|
||||
|
||||
if (vm->memory)
|
||||
{
|
||||
vm_host_free(vm, vm->memory);
|
||||
|
||||
Reference in New Issue
Block a user