/* StarForth — Steady-State Virtual Machine Runtime Copyright (c) 2023–2025 Robert A. James All rights reserved. This file is part of the StarForth project. Licensed under the StarForth License, Version 1.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://github.com/star.4th@proton.me/StarForth/LICENSE.txt This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. See the License for the specific language governing permissions and limitations under the License. */ /** * log_region.c - Growable per-VM log-persistence ring on Artemis's disk. * See starkernel/log_region.h for the format/interface design. * * Deliberately never calls log_message() (or anything documented to call * it) -- this runs inside Artemis's own dictionary context during message * delivery (LOG-APPEND, dispatched by a sender's MSG-SEND/MSG-TICK); a log * call here would append to Artemis's own buffer and eventually flush back * through this same path. */ #include "starkernel/log_region.h" #include #include #include "block_subsystem.h" /* blk_meta_zone_read/write, compute_crc64 */ static uint64_t log_region_ctrl_compute_crc(const log_region_ctrl_t *ctrl) { size_t crc_span = offsetof(log_region_ctrl_t, hdr_crc); return compute_crc64((const uint8_t *)ctrl, crc_span); } /* Reads the control header; returns 0 and *out populated only if magic, * version, and CRC all check out. Any other result (blank, foreign, * corrupt, or a read failure) is treated identically by the caller: "not * yet initialized," matching genesis_marker_read()'s own discipline. */ static int log_region_ctrl_read(log_region_ctrl_t *out) { if (blk_meta_zone_read(LOG_REGION_DEVBLOCK_FROM_TOP_BASE, (uint8_t *)out) != 0) return -1; if (LOG_REGION_GET_MAGIC(out->magic) != (uint32_t)(LOG_REGION_MAGIC & 0xFFFFFFFFull)) return -1; if (LOG_REGION_GET_VERSION(out->magic) != LOG_REGION_VERSION_0) return -1; uint64_t want_crc = log_region_ctrl_compute_crc(out); if (want_crc != out->hdr_crc) return -1; return 0; } static int log_region_ctrl_write(log_region_ctrl_t *ctrl) { ctrl->hdr_crc = log_region_ctrl_compute_crc(ctrl); return blk_meta_zone_write(LOG_REGION_DEVBLOCK_FROM_TOP_BASE, (const uint8_t *)ctrl) == 0 ? 0 : -1; } static void log_region_ctrl_init_fresh(log_region_ctrl_t *ctrl) { memset(ctrl, 0, sizeof(*ctrl)); ctrl->magic = LOG_REGION_PACK(LOG_REGION_VERSION_0); ctrl->devblocks = LOG_REGION_INITIAL_DEVBLOCKS; ctrl->head_slot = 0; ctrl->tail_slot = 0; ctrl->record_count = 0; } /* Reads/writes the devblock holding slot_index (LOG_SLOTS_PER_DEVBLOCK * slots per devblock), one devblock_from_top past the control header for * every LOG_SLOTS_PER_DEVBLOCK slots. */ static uint32_t log_region_slot_devblock_from_top(uint32_t slot_index) { return LOG_REGION_DEVBLOCK_FROM_TOP_BASE + 1u + (slot_index / LOG_SLOTS_PER_DEVBLOCK); } static int log_region_read_slot(uint32_t slot_index, log_slot_t *out) { uint8_t devblock_buf[4096]; uint32_t dft = log_region_slot_devblock_from_top(slot_index); uint32_t slot_in_devblock = slot_index % LOG_SLOTS_PER_DEVBLOCK; if (blk_meta_zone_read(dft, devblock_buf) != 0) return -1; memcpy(out, devblock_buf + (size_t)slot_in_devblock * LOG_SLOT_SIZE, sizeof(*out)); return 0; } static int log_region_write_slot(uint32_t slot_index, const log_slot_t *slot) { uint8_t devblock_buf[4096]; uint32_t dft = log_region_slot_devblock_from_top(slot_index); uint32_t slot_in_devblock = slot_index % LOG_SLOTS_PER_DEVBLOCK; /* Read-modify-write: blk_meta_zone_*() only operates at whole-devblock * granularity, and a devblock holds LOG_SLOTS_PER_DEVBLOCK slots, so a * single-slot write must preserve its siblings. A read failure on a * never-yet-written devblock (blank fence content) is not fatal here -- * proceed with a zeroed buffer: blank fence content is * indistinguishable from absent, and is treated as absent. */ if (blk_meta_zone_read(dft, devblock_buf) != 0) { memset(devblock_buf, 0, sizeof(devblock_buf)); } memcpy(devblock_buf + (size_t)slot_in_devblock * LOG_SLOT_SIZE, slot, sizeof(*slot)); return blk_meta_zone_write(dft, devblock_buf) == 0 ? 0 : -1; } int log_region_append(uint8_t level, uint64_t timestamp, const char *source, uint32_t source_len, const char *msg, uint32_t msg_len) { log_region_ctrl_t ctrl; if (log_region_ctrl_read(&ctrl) != 0) { log_region_ctrl_init_fresh(&ctrl); } uint32_t total_slots = ctrl.devblocks * LOG_SLOTS_PER_DEVBLOCK; if (ctrl.record_count >= total_slots) { if (ctrl.devblocks < LOG_REGION_MAX_DEVBLOCKS) { /* Grow: new slots appear at indices [old_total .. new_total-1]. * The ring is full here, so head == tail, and in ring order the * records run head..old_total-1 then 0..head-1. The new slots * must come after the newest record and before the oldest, so * the records from head on move up by the slots added, and the * next write goes into the gap they leave (or, with head at 0, * simply at old_total). Until 2026-10-14 nothing moved and * tail stayed put: the next write overwrote the oldest record, * and the blank new slots read back as records inside the live * range (FABRIC-3.md SXXXVIII -- first exercised by LOG-FLUSH). */ uint32_t old_total = total_slots, added, i; ctrl.devblocks += LOG_REGION_GROWTH_INCREMENT; if (ctrl.devblocks > LOG_REGION_MAX_DEVBLOCKS) ctrl.devblocks = LOG_REGION_MAX_DEVBLOCKS; total_slots = ctrl.devblocks * LOG_SLOTS_PER_DEVBLOCK; added = total_slots - old_total; if (ctrl.head_slot == 0) { ctrl.tail_slot = old_total; } else { for (i = old_total; i-- > ctrl.head_slot; ) { log_slot_t moving; if (log_region_read_slot(i, &moving) != 0) return -1; if (log_region_write_slot(i + added, &moving) != 0) return -1; } ctrl.tail_slot = ctrl.head_slot; ctrl.head_slot += added; } } if (ctrl.record_count >= total_slots) { /* Still full (growth ceiling already reached) -- evict oldest, * unless doing so would discard higher-priority history for a * lower-priority newcomer (FABRIC-3.md §XXXII.4, 2026-09-15: * plain FIFO eviction here was flagged as level-blind -- a * flood of INFO/DEBUG noise could push out real ERROR/WARN * records). Read the oldest slot's own level before evicting: * if it's ERROR/WARN and the incoming record is not, protect * it -- drop the incoming record instead of evicting real * history. Any other combination (oldest is already low * priority, or the incoming record is itself ERROR/WARN) * evicts exactly as before. A read failure on the oldest slot * (should not happen -- it was written by this same function) * falls back to the prior unconditional-eviction behavior * rather than wedging the ring. */ log_slot_t oldest; int oldest_protected = 0; if (log_region_read_slot(ctrl.head_slot, &oldest) == 0) { oldest_protected = (oldest.level <= LOG_REGION_PROTECTED_MAX_LEVEL); } int incoming_protected = (level <= LOG_REGION_PROTECTED_MAX_LEVEL); if (oldest_protected && !incoming_protected) { return 1; /* dropped by design, not a failure -- see header */ } ctrl.head_slot = (ctrl.head_slot + 1u) % total_slots; ctrl.record_count--; } } log_slot_t slot; memset(&slot, 0, sizeof(slot)); slot.timestamp = timestamp; slot.level = level; uint32_t src_n = source_len; if (src_n > LOG_SLOT_SOURCE_MAX) src_n = LOG_SLOT_SOURCE_MAX; memcpy(slot.source, source, src_n); uint32_t msg_n = msg_len; if (msg_n > LOG_SLOT_MSG_MAX) msg_n = LOG_SLOT_MSG_MAX; memcpy(slot.msg, msg, msg_n); slot.msg_len = (uint16_t)msg_n; if (log_region_write_slot(ctrl.tail_slot, &slot) != 0) return -1; ctrl.tail_slot = (ctrl.tail_slot + 1u) % total_slots; if (ctrl.record_count < total_slots) ctrl.record_count++; return log_region_ctrl_write(&ctrl); } int log_region_read_recent(uint32_t back, log_slot_t *out) { log_region_ctrl_t ctrl; uint32_t total_slots; if (!out) return -1; if (log_region_ctrl_read(&ctrl) != 0) return -1; if (back >= ctrl.record_count) return 1; total_slots = ctrl.devblocks * LOG_SLOTS_PER_DEVBLOCK; if (total_slots == 0) return 1; return log_region_read_slot((ctrl.tail_slot + total_slots - 1u - back) % total_slots, out); }