Skip to content

feat(clock): add clock module for slot/epoch timing - #354

Merged
wemeetagain merged 61 commits into
mainfrom
gr/clock-replant
Jun 30, 2026
Merged

wemeetagain merged 61 commits into
mainfrom
gr/clock-replant

Conversation

@GrapeBaBa

@GrapeBaBa GrapeBaBa commented May 8, 2026

Copy link
Copy Markdown
Contributor

Motivation

Lodestar (TS) needs a beacon clock that emits slot/epoch events and lets callers wait on specific slots. This adds the equivalent in Zig as a self-contained module under src/clock/, ported from ChainSafe/clock-zig and adapted to Zig 0.16's std.Io async model.

Description

Three-layer architecture:

  • Layer 0 (slot_math) — pure arithmetic, comptime-compatible.
  • Layer 1 (SlotClock) — stateful clock with pluggable TimeSource.
  • Layer 2 (EventClock) — async event loop with listeners and waiters; built on std.Io.

Three-layer beacon clock ported from ChainSafe/clock-zig, rebased onto
current main:

- Layer 0 (`slot_math`) — pure arithmetic, comptime-compatible
- Layer 1 (`SlotClock`) — stateful clock with pluggable `TimeSource`
- Layer 2 (`EventClock`) — async event loop with listeners and waiters

Hooked into zbuild via `.modules.clock` + `.tests.clock`. Self-contained
(no internal lodestar-z deps), so the module entry needs no imports.

Module is `std.Io`-generic. Tests exercise it through `std.Io.Threaded`
to avoid Zig 0.16.0 stdlib bugs in `Io.Dispatch` (macOS) and
`Io.Uring` (Linux).

Concurrency:
- `mutex: std.Io.Mutex` covers all mutable shared state (waiters,
  listeners, snapshots, `next_listener_id`, `clock.current_slot`).
- `stopped: std.atomic.Value(bool)` so `runAutoLoop` can read it
  lock-free between sleeps.
- Cancelable `lock(io)` for normal paths so `error.Canceled` propagates
  naturally to callers' next cancelation point; `lockUncancelable` only
  on cleanup paths (`WaitForSlotResult.cancel`, `abortAllWaiters`).
- Listener callbacks run while the mutex is held — they must not call
  back into EventClock methods (documented on `advanceAndDispatch`).

`WaitForSlotResult` is a tagged union (`immediate` / `pending`) — no
peeking into `std.Io.Future` internal fields.

Replaces the long-running 0.16-migration branch which had drifted 40
commits behind main; only the clock-specific work is preserved here on
top of current main (zbuild-based build).
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request integrates a new, highly modular clock system into the project, crucial for precise slot and epoch timing in a Zig 0.16 environment. The design emphasizes a clear separation of concerns, from fundamental time arithmetic to event-driven asynchronous operations, enhancing both maintainability and testability. By streamlining the implementation and removing previous migration overhead, this change provides a clean and efficient foundation for time-sensitive blockchain operations.

Highlights

  • New Clock Module: Introduced a comprehensive clock module for managing slot and epoch timing, specifically designed for Zig 0.16.
  • Three-Layer Architecture: Implemented a layered architecture consisting of slot_math (pure arithmetic), SlotClock (stateful clock with pluggable time sources), and EventClock (async event loop with listeners and waiters).
  • Refined Implementation: This pull request is a re-implementation of a previous attempt (PR feat: add clock module for slot/epoch timing (Zig 0.16) #301), now replanted onto the current main branch, significantly reducing unrelated migration commits and focusing purely on clock functionality.
  • Concurrency Model: The EventClock employs a coarse-grained mutex for mutable shared state and an atomic flag for lock-free reads in its auto-advance loop, ensuring thread safety.
  • Pluggable I/O Backend: The module is std.Io-generic, allowing production callers to use any std.Io implementation, while tests utilize std.Io.Threaded to circumvent known Zig 0.16.0 standard library bugs.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@GrapeBaBa GrapeBaBa changed the title feat: add clock module for slot/epoch timing (Zig 0.16) feat(wip): add clock module for slot/epoch timing (Zig 0.16) May 8, 2026
@GrapeBaBa
GrapeBaBa marked this pull request as draft May 8, 2026 07:33

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a three-layer beacon clock system for Ethereum consensus, comprising pure arithmetic, stateful tracking, and an async event-driven clock. A critical deadlock risk was identified in EventClock due to holding a mutex while invoking external listener callbacks. Additionally, the implementation violates repository style guides regarding the required minimum assertion density and the necessity of fixed upper bounds for all queues and lists. A minor improvement for more idiomatic error handling was also suggested.

Comment thread src/clock/EventClock.zig Outdated
Comment on lines +458 to +485
fn advanceAndDispatch(self: *EventClock, target: Slot) std.Io.Cancelable!void {
try self.mutex.lock(self.io);
defer self.mutex.unlock(self.io);
var iter = self.clock.advanceTo(target);
while (iter.next()) |event| {
if (self.stopped.load(.acquire)) break;
switch (event) {
.slot => |s| {
self.snapshotSlotListenersLocked();
self.dispatchWaitersLocked(s);
for (self.slot_snapshot.items) |listener| {
listener.callback(listener.ctx, s);
}
},
.epoch => |e| {
self.snapshotEpochListenersLocked();
for (self.epoch_snapshot.items) |listener| {
listener.callback(listener.ctx, e);
}
},
}
}
// Defensive: handles edge cases where advanceTo yields zero events
// (already at target) but waiters were added between loop ticks.
// In the normal case, this is a no-op because the last .slot event
// already dispatched waiters at the same slot value.
self.dispatchWaitersLocked(self.clock.current_slot);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Holding the mutex while invoking external listener callbacks (lines 469 and 475) creates a significant deadlock risk. If a callback attempts to call any EventClock method that triggers a catchUp() (like currentSlot()), it will attempt to re-acquire the same mutex on the same thread/fiber, leading to a deadlock. This is a violation of the 'Safety' design goal. Consider collecting events and their snapshots into a local buffer while holding the lock, then releasing the lock before iterating and invoking the callbacks.

References
  1. Use only very simple, explicit control flow for clarity. Assertions downgrade catastrophic correctness bugs into liveness bugs. (link)

Comment thread src/clock/slot_math.zig
Comment on lines +1 to +92
//! Layer 0 – Pure slot/epoch arithmetic.
//!
//! No state, no allocation, no I/O. Every function is comptime-compatible.
//! All overflow paths return `null` (`?T`) instead of panicking.

const std = @import("std");

// ── Type aliases ──────────────────────────────────────────────────────

pub const Slot = u64;
pub const Epoch = u64;
pub const UnixMs = u64;
pub const UnixSec = u64;

// ── Config ────────────────────────────────────────────────────────────

pub const Config = struct {
genesis_time_sec: UnixSec,
seconds_per_slot: u64,
slots_per_epoch: u64,
maximum_gossip_clock_disparity_ms: u64 = 500,

/// Validates that the config is usable (no zero divisors, no sec→ms overflow).
pub fn validate(self: Config) error{InvalidConfig}!void {
if (self.seconds_per_slot == 0) return error.InvalidConfig;
if (self.slots_per_epoch == 0) return error.InvalidConfig;
// Ensure sec→ms conversions used by msUntilNextSlot won't overflow at runtime.
if (secToMs(self.genesis_time_sec) == null) return error.InvalidConfig;
if (secToMs(self.seconds_per_slot) == null) return error.InvalidConfig;
}

/// Returns the slot duration in milliseconds, or null on overflow.
pub fn slotDurationMs(self: Config) ?u64 {
return secToMs(self.seconds_per_slot);
}
};

/// Returns the slot at the given Unix-millisecond timestamp,
/// or null if pre-genesis or on overflow.
pub fn slotAtMs(config: Config, now_ms: UnixMs) ?Slot {
const genesis_ms = secToMs(config.genesis_time_sec) orelse return null;
if (now_ms < genesis_ms) return null;
const slot_ms = secToMs(config.seconds_per_slot) orelse return null;
if (slot_ms == 0) return null;
return @divFloor(now_ms - genesis_ms, slot_ms);
}

/// Returns the slot at the given Unix-second timestamp,
/// or null if pre-genesis.
pub fn slotAtSec(config: Config, now_sec: UnixSec) ?Slot {
if (now_sec < config.genesis_time_sec) return null;
if (config.seconds_per_slot == 0) return null;
return @divFloor(now_sec - config.genesis_time_sec, config.seconds_per_slot);
}

/// Returns the epoch that contains `slot`, or null if slots_per_epoch is zero.
pub fn epochAtSlot(config: Config, slot: Slot) ?Epoch {
if (config.slots_per_epoch == 0) return null;
return @divFloor(slot, config.slots_per_epoch);
}

/// Returns the Unix-second start time of `slot`, or null on overflow.
pub fn slotStartSec(config: Config, slot: Slot) ?UnixSec {
const offset = std.math.mul(u64, slot, config.seconds_per_slot) catch return null;
return std.math.add(u64, config.genesis_time_sec, offset) catch return null;
}

/// Returns the Unix-millisecond start time of `slot`, or null on overflow.
pub fn slotStartMs(config: Config, slot: Slot) ?UnixMs {
const sec = slotStartSec(config, slot) orelse return null;
return secToMs(sec);
}

/// Milliseconds until the next slot boundary.
/// Pre-genesis: returns the time until genesis.
/// Returns null only on arithmetic overflow.
pub fn msUntilNextSlot(config: Config, now_ms: UnixMs) ?u64 {
const genesis_ms = secToMs(config.genesis_time_sec) orelse return null;
const slot_ms = secToMs(config.seconds_per_slot) orelse return null;
if (slot_ms == 0) return null;

if (now_ms < genesis_ms) return genesis_ms - now_ms;

const delta = now_ms - genesis_ms;
const rem = delta % slot_ms;
if (rem == 0) return slot_ms;
return slot_ms - rem;
}

fn secToMs(sec: u64) ?u64 {
return std.math.mul(u64, sec, 1000) catch return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The repository style guide requires an average assertion density of at least two assertions per function (Line 54). This file contains several pure arithmetic functions with zero assertions. Adding assertions for invariants (e.g., assert(config.seconds_per_slot > 0) in slotAtMs) would improve safety and documentation by encoding the mental model of the code as per the 'Safety' design goals.

References
  1. The assertion density of the code must average a minimum of two assertions per function. (link)

Comment thread src/clock/SlotClock.zig
Comment on lines +1 to +175
//! Layer 1 – Stateful slot clock.
//!
//! Wraps `slot_math` with a `TimeSource` and a cached `current_slot`.
//! Pure-read helpers query wall-clock time; only `advanceTo()` mutates the cache.

const std = @import("std");
const slot_math = @import("slot_math.zig");
const time_source = @import("time_source.zig");

const SlotClock = @This();

pub const Slot = slot_math.Slot;
pub const Epoch = slot_math.Epoch;
pub const Config = slot_math.Config;
pub const TimeSource = time_source.TimeSource;

pub const Event = union(enum) {
slot: Slot,
epoch: Epoch,
};

pub const AdvanceIterator = struct {
clock: *SlotClock,
target: Slot,
pending_epoch: ?Epoch = null,

/// Advances the clock one step at a time, yielding slot and epoch events.
/// For each slot advancement: yields .slot first, then .epoch if an epoch boundary was crossed.
/// Returns null when caught up to target.
pub fn next(self: *AdvanceIterator) ?Event {
// If we have a pending epoch event from the previous step, emit it now
if (self.pending_epoch) |epoch| {
self.pending_epoch = null;
return .{ .epoch = epoch };
}

const current = self.clock.current_slot;

// Genesis case: current_slot is null, advance to slot 0
if (current == null) {
self.clock.current_slot = 0;
return .{ .slot = 0 };
}

const cur = current.?;
if (cur >= self.target) return null;
if (cur == std.math.maxInt(Slot)) return null;

const next_slot = cur + 1;
self.clock.current_slot = next_slot;

// Check epoch boundary — epochAtSlot returns ?Epoch
const prev_epoch = slot_math.epochAtSlot(self.clock.config, cur);
const new_epoch = slot_math.epochAtSlot(self.clock.config, next_slot);
if (prev_epoch) |prev_ep| {
if (new_epoch) |new_ep| {
if (prev_ep < new_ep) {
self.pending_epoch = new_ep;
}
}
}

return .{ .slot = next_slot };
}
};

config: Config,
time: TimeSource,
current_slot: ?Slot = null,

pub fn init(config: Config, time: TimeSource) error{InvalidConfig}!SlotClock {
try config.validate();
var self = SlotClock{
.config = config,
.time = time,
};
self.current_slot = slot_math.slotAtMs(config, time.nowMs());
return self;
}

/// Returns the current wall-clock slot. Pure read — does NOT update
/// the internal `current_slot` cache. Only `advanceTo()` advances the cache.
pub fn currentSlot(self: *const SlotClock) ?Slot {
const now_ms = self.time.nowMs();
return slot_math.slotAtMs(self.config, now_ms);
}

pub fn currentEpoch(self: *const SlotClock) ?Epoch {
const slot = self.currentSlot() orelse return null;
return slot_math.epochAtSlot(self.config, slot);
}

pub fn currentSlotOrGenesis(self: *const SlotClock) Slot {
return self.currentSlot() orelse 0;
}

pub fn currentEpochOrGenesis(self: *const SlotClock) Epoch {
return self.currentEpoch() orelse 0;
}

pub fn currentSlotWithGossipDisparity(self: *const SlotClock) Slot {
const current = self.currentSlotOrGenesis();
if (current == std.math.maxInt(Slot)) return current;
const now_ms = self.time.nowMs();
const next_slot = current + 1;
const next_slot_ms = slot_math.slotStartMs(self.config, next_slot) orelse return current;
if (next_slot_ms -| now_ms < self.config.maximum_gossip_clock_disparity_ms) {
return next_slot;
}
return current;
}

pub fn isCurrentSlotGivenGossipDisparity(self: *const SlotClock, slot: Slot) bool {
const current = self.currentSlotOrGenesis();
if (slot == current) return true;

const now_ms = self.time.nowMs();

// Check if close to next slot
if (current != std.math.maxInt(Slot)) {
const next_slot = current + 1;
const next_slot_ms = slot_math.slotStartMs(self.config, next_slot) orelse return false;
if (next_slot_ms -| now_ms < self.config.maximum_gossip_clock_disparity_ms) {
return slot == next_slot;
}
}

// Check if just passed current slot boundary
if (current > 0) {
const current_slot_ms = slot_math.slotStartMs(self.config, current) orelse return false;
if (now_ms -| current_slot_ms < self.config.maximum_gossip_clock_disparity_ms) {
return slot == current - 1;
}
}

return false;
}

pub fn slotWithFutureTolerance(self: *const SlotClock, tolerance_ms: u64) ?Slot {
const now_ms = self.time.nowMs();
const shifted = @addWithOverflow(now_ms, tolerance_ms);
if (shifted[1] != 0) return null;
return slot_math.slotAtMs(self.config, shifted[0]);
}

pub fn slotWithPastTolerance(self: *const SlotClock, tolerance_ms: u64) ?Slot {
const now_ms = self.time.nowMs();
// Checked sub: underflow (pre-UNIX-epoch) returns null.
// Pre-genesis but valid timestamp returns 0.
const shifted_ms = std.math.sub(u64, now_ms, tolerance_ms) catch return null;
return slot_math.slotAtMs(self.config, shifted_ms) orelse 0;
}

pub fn secFromSlot(self: *const SlotClock, slot: Slot, to_sec: ?slot_math.UnixSec) ?i64 {
const from_sec = slot_math.slotStartSec(self.config, slot) orelse return null;
const end_sec = to_sec orelse @divFloor(self.time.nowMs(), 1000);
const diff = @as(i128, @intCast(end_sec)) - @as(i128, @intCast(from_sec));
if (diff < std.math.minInt(i64) or diff > std.math.maxInt(i64)) return null;
return @intCast(diff);
}

pub fn msFromSlot(self: *const SlotClock, slot: Slot, to_ms: ?slot_math.UnixMs) ?i64 {
const from_ms = slot_math.slotStartMs(self.config, slot) orelse return null;
const end_ms = to_ms orelse self.time.nowMs();
const diff = @as(i128, @intCast(end_ms)) - @as(i128, @intCast(from_ms));
if (diff < std.math.minInt(i64) or diff > std.math.maxInt(i64)) return null;
return @intCast(diff);
}

pub fn advanceTo(self: *SlotClock, target: Slot) AdvanceIterator {
return .{
.clock = self,
.target = target,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to slot_math.zig, this file lacks the required assertion density. Functions like init, currentSlot, and advanceTo should include assertions to verify arguments and internal state invariants, adhering to the repository style guide (Line 54). For example, init could assert that the provided TimeSource is valid or that the initial current_slot calculation succeeded.

References
  1. The assertion density of the code must average a minimum of two assertions per function. (link)

Comment thread src/clock/EventClock.zig
Comment on lines +1 to +528
//! Layer 2 – Event-driven beacon clock.
//!
//! Combines `SlotClock` with an async I/O loop to emit slot/epoch events
//! and dispatch waiters. All public methods are safe to call from the
//! main thread; the internal loop runs as a single cooperative fiber.

const std = @import("std");
const Allocator = std.mem.Allocator;
const slot_math = @import("slot_math.zig");
const SlotClock = @import("SlotClock.zig");
const time_source = @import("time_source.zig");

const EventClock = @This();

pub const Slot = slot_math.Slot;
pub const Epoch = slot_math.Epoch;
pub const Config = slot_math.Config;
pub const ListenerId = u64;
pub const TimeSource = time_source.TimeSource;

pub const Error = error{
InvalidConfig,
OutOfMemory,
ListenerLimitReached,
Aborted,
Canceled,
};

const WaitState = struct {
io: std.Io,
allocator: Allocator,
event: std.Io.Event = .unset,
aborted: bool = false,
};

const WaiterEntry = struct {
target: Slot,
state: *WaitState,
};

const SlotListenerEntry = struct {
id: ListenerId,
callback: *const fn (ctx: ?*anyopaque, slot: Slot) void,
ctx: ?*anyopaque,
};

const EpochListenerEntry = struct {
id: ListenerId,
callback: *const fn (ctx: ?*anyopaque, epoch: Epoch) void,
ctx: ?*anyopaque,
};

const SlotSnapshot = struct {
callback: *const fn (ctx: ?*anyopaque, slot: Slot) void,
ctx: ?*anyopaque,
};

const EpochSnapshot = struct {
callback: *const fn (ctx: ?*anyopaque, epoch: Epoch) void,
ctx: ?*anyopaque,
};

const WaiterQueue = std.PriorityQueue(WaiterEntry, void, struct {
fn compare(_: void, a: WaiterEntry, b: WaiterEntry) std.math.Order {
return std.math.order(a.target, b.target);
}
}.compare);

allocator: Allocator,
io: std.Io,
clock: SlotClock,

/// Coarse-grained mutex covering all mutable state below (waiters,
/// listeners, snapshots, next_listener_id, clock.current_slot via
/// advanceAndDispatch). Required when `io` is a multi-threaded backend
/// (`std.Io.Threaded`); cheap on a single-threaded fiber backend
/// (`std.Io.Evented`) — uncontended fast path is one atomic CAS.
///
/// Listener callbacks run WHILE this mutex is held (see
/// `advanceAndDispatch`); they must not call back into `EventClock`.
mutex: std.Io.Mutex = .init,

/// Read lock-free from `runAutoLoop` so the loop's sleep does not need
/// to hold the mutex.
stopped: std.atomic.Value(bool) = .init(false),
loop_future: ?std.Io.Future(void) = null,

next_listener_id: ListenerId = 1,
slot_listeners: std.ArrayListUnmanaged(SlotListenerEntry) = .empty,
epoch_listeners: std.ArrayListUnmanaged(EpochListenerEntry) = .empty,
slot_snapshot: std.ArrayListUnmanaged(SlotSnapshot) = .empty,
epoch_snapshot: std.ArrayListUnmanaged(EpochSnapshot) = .empty,

waiters: WaiterQueue,

/// Initialise in-place.
pub fn init(self: *EventClock, allocator: Allocator, config: Config, io_handle: std.Io) Error!void {
self.* = .{
.allocator = allocator,
.io = io_handle,
.clock = undefined,
.waiters = WaiterQueue.initContext({}),
};
self.clock = SlotClock.init(config, .{ .real = .{ .io = io_handle } }) catch return error.InvalidConfig;
}

/// Start the auto-advance loop. Idempotent; second call is a no-op.
pub fn start(self: *EventClock) void {
if (self.loop_future != null) return;
self.loop_future = std.Io.async(self.io, EventClock.runAutoLoop, .{self});
}

/// Signal the loop to stop and abort all pending waiters. Idempotent.
pub fn stop(self: *EventClock) void {
if (self.stopped.swap(true, .acq_rel)) return;
self.abortAllWaiters();
}

/// Signal the loop to stop, cancel the fiber, and wait for it to finish.
pub fn join(self: *EventClock) void {
self.stop();
var maybe_future = self.loop_future;
self.loop_future = null;
if (maybe_future) |*future| {
future.cancel(self.io);
future.await(self.io);
}
}

/// Release all resources. Calls `stop()` + `join()` internally.
pub fn deinit(self: *EventClock) void {
self.stop();
self.join();
self.slot_snapshot.deinit(self.allocator);
self.epoch_snapshot.deinit(self.allocator);
self.slot_listeners.deinit(self.allocator);
self.epoch_listeners.deinit(self.allocator);
self.waiters.deinit(self.allocator);
self.* = undefined;
}

// ── Listener API ──
// NOTE: Listeners should be registered before calling `start()`.
// Adding listeners from within a callback may silently skip the new listener
// until the next slot, because snapshot buffers are pre-allocated at registration
// time and the snapshot helpers only use pre-allocated capacity.

/// Register a slot listener. Returns an ID for later removal via `offSlot`.
pub fn onSlot(
self: *EventClock,
callback: *const fn (ctx: ?*anyopaque, slot: Slot) void,
ctx: ?*anyopaque,
) Error!ListenerId {
try self.mutex.lock(self.io);
defer self.mutex.unlock(self.io);
if (self.next_listener_id == std.math.maxInt(ListenerId)) return error.ListenerLimitReached;
// Pre-allocate snapshot buffer BEFORE appending the listener, so that
// if OOM occurs we haven't modified any state yet.
self.slot_snapshot.ensureTotalCapacity(
self.allocator,
self.slot_listeners.items.len + 1,
) catch return error.OutOfMemory;
self.slot_listeners.append(self.allocator, .{
.id = self.next_listener_id,
.callback = callback,
.ctx = ctx,
}) catch return error.OutOfMemory;
const id = self.next_listener_id;
self.next_listener_id += 1;
return id;
}

/// Unregister a slot listener. Returns `true` if found and removed.
pub fn offSlot(self: *EventClock, id: ListenerId) Error!bool {
try self.mutex.lock(self.io);
defer self.mutex.unlock(self.io);
for (self.slot_listeners.items, 0..) |listener, i| {
if (listener.id == id) {
_ = self.slot_listeners.orderedRemove(i);
return true;
}
}
return false;
}

/// Register an epoch listener. Returns an ID for later removal via `offEpoch`.
pub fn onEpoch(
self: *EventClock,
callback: *const fn (ctx: ?*anyopaque, epoch: Epoch) void,
ctx: ?*anyopaque,
) Error!ListenerId {
try self.mutex.lock(self.io);
defer self.mutex.unlock(self.io);
if (self.next_listener_id == std.math.maxInt(ListenerId)) return error.ListenerLimitReached;
// Pre-allocate snapshot buffer BEFORE appending the listener, so that
// if OOM occurs we haven't modified any state yet.
self.epoch_snapshot.ensureTotalCapacity(
self.allocator,
self.epoch_listeners.items.len + 1,
) catch return error.OutOfMemory;
self.epoch_listeners.append(self.allocator, .{
.id = self.next_listener_id,
.callback = callback,
.ctx = ctx,
}) catch return error.OutOfMemory;
const id = self.next_listener_id;
self.next_listener_id += 1;
return id;
}

/// Unregister an epoch listener. Returns `true` if found and removed.
pub fn offEpoch(self: *EventClock, id: ListenerId) Error!bool {
try self.mutex.lock(self.io);
defer self.mutex.unlock(self.io);
for (self.epoch_listeners.items, 0..) |listener, i| {
if (listener.id == id) {
_ = self.epoch_listeners.orderedRemove(i);
return true;
}
}
return false;
}

// ── Delegated read APIs ──
// Every public accessor that exposes "current" slot/epoch state calls catchUp()
// first, matching the TS version where `get currentSlot()` triggers event
// emission before returning. Pure time-arithmetic helpers (slotWithFutureTolerance,
// secFromSlot, etc.) do NOT catch up, matching TS which doesn't go through
// `this.currentSlot` for those.

pub fn currentSlot(self: *EventClock) std.Io.Cancelable!?Slot {
try self.catchUp();
return self.clock.currentSlot();
}

pub fn currentEpoch(self: *EventClock) std.Io.Cancelable!?Epoch {
try self.catchUp();
return self.clock.currentEpoch();
}

pub fn currentSlotOrGenesis(self: *EventClock) std.Io.Cancelable!Slot {
try self.catchUp();
return self.clock.currentSlotOrGenesis();
}

pub fn currentEpochOrGenesis(self: *EventClock) std.Io.Cancelable!Epoch {
try self.catchUp();
return self.clock.currentEpochOrGenesis();
}

pub fn currentSlotWithGossipDisparity(self: *EventClock) std.Io.Cancelable!Slot {
try self.catchUp();
return self.clock.currentSlotWithGossipDisparity();
}

pub fn isCurrentSlotGivenGossipDisparity(self: *EventClock, slot: Slot) std.Io.Cancelable!bool {
try self.catchUp();
return self.clock.isCurrentSlotGivenGossipDisparity(slot);
}

pub fn slotWithFutureTolerance(self: *EventClock, tolerance_ms: u64) ?Slot {
return self.clock.slotWithFutureTolerance(tolerance_ms);
}

pub fn slotWithPastTolerance(self: *EventClock, tolerance_ms: u64) ?Slot {
return self.clock.slotWithPastTolerance(tolerance_ms);
}

pub fn secFromSlot(self: *EventClock, slot: Slot, to_sec: ?slot_math.UnixSec) ?i64 {
return self.clock.secFromSlot(slot, to_sec);
}

pub fn msFromSlot(self: *EventClock, slot: Slot, to_ms: ?slot_math.UnixMs) ?i64 {
return self.clock.msFromSlot(slot, to_ms);
}

// ── waitForSlot ──

/// Return type from `waitForSlot`. The caller MUST either:
/// - call `await()` to wait for the target slot and release resources, OR
/// - call `cancel()` to abort and release resources, OR
/// - call `stop()` on the EventClock and THEN `await()` to get `error.Aborted`.
/// Dropping a WaitForSlotResult without calling `await` or `cancel` leaks
/// the internal WaitState.
///
/// Idiomatic usage with `errdefer`:
/// var fut = try ec.waitForSlot(target);
/// errdefer fut.cancel();
/// try fut.await();
pub const WaitForSlotResult = union(enum) {
immediate: Error!void,
pending: Pending,

pub const Pending = struct {
inner: std.Io.Future(Error!void),
state: *WaitState,
clock: *EventClock,
};

pub fn await(self: *WaitForSlotResult) Error!void {
switch (self.*) {
.immediate => |r| return r,
.pending => |*p| {
// Use the io that created the future to avoid io-mismatch bugs.
const result = p.inner.await(p.state.io);
// Free AFTER await returns — workaround for Zig futex
// use-after-free where GCD still holds a reference to the
// event address after wake.
p.state.allocator.destroy(p.state);
self.* = .{ .immediate = result };
return result;
},
}
}

/// Abort a pending wait and release its resources. Idempotent — safe
/// to call on an already-awaited, already-cancelled, or immediate result.
pub fn cancel(self: *WaitForSlotResult) void {
switch (self.*) {
.immediate => return,
.pending => |*p| {
// Remove from waiter queue before freeing, so abortAllWaiters
// won't dereference the freed state pointer.
p.clock.mutex.lockUncancelable(p.clock.io);
for (p.clock.waiters.items, 0..) |entry, i| {
if (entry.state == p.state) {
_ = p.clock.waiters.popIndex(i);
break;
}
}
p.clock.mutex.unlock(p.clock.io);
p.state.aborted = true;
p.state.event.set(p.state.io);
// Must await the fiber so it finishes before we free its state.
// The fiber returns error.Aborted (expected) or {} (already dispatched).
_ = p.inner.await(p.state.io) catch |err| {
std.debug.assert(err == error.Aborted);
};
p.state.allocator.destroy(p.state);
self.* = .{ .immediate = error.Aborted };
},
}
}
};

/// Return a future that resolves when the clock reaches `target`.
/// See `WaitForSlotResult` for the caller's obligations.
pub fn waitForSlot(self: *EventClock, target: Slot) Error!WaitForSlotResult {
if (self.stopped.load(.acquire)) return .{ .immediate = error.Aborted };
// Catch up events then check fast-path against advanced state.
// catchUp invokes listener callbacks, so we must NOT hold the mutex
// here — `advanceAndDispatch` takes it internally per state read.
try self.catchUp();

try self.mutex.lock(self.io);
if (self.clock.current_slot) |slot| {
if (slot >= target) {
self.mutex.unlock(self.io);
return .{ .immediate = {} };
}
}
if (self.stopped.load(.acquire)) {
self.mutex.unlock(self.io);
return .{ .immediate = error.Aborted };
}

const state = self.allocator.create(WaitState) catch {
self.mutex.unlock(self.io);
return error.OutOfMemory;
};
state.* = .{
.io = self.io,
.allocator = self.allocator,
};

self.waiters.push(self.allocator, .{
.target = target,
.state = state,
}) catch {
self.allocator.destroy(state);
self.mutex.unlock(self.io);
return error.OutOfMemory;
};
self.dispatchWaitersLocked(self.clock.current_slot);
// Release before spawning the async task — async spawn is quick but
// shouldn't be inside the EventClock mutex.
self.mutex.unlock(self.io);

return .{ .pending = .{
.inner = std.Io.async(self.io, waitForSlotFutureAwait, .{state}),
.state = state,
.clock = self,
} };
}

// ── Private ──

/// Ensure event-clock state is caught up to wall-clock time.
/// Emits any intermediate slot/epoch events to listeners.
/// No-op if already caught up or pre-genesis (currentSlot() returns null).
fn catchUp(self: *EventClock) std.Io.Cancelable!void {
if (self.clock.currentSlot()) |wall_slot| {
try self.advanceAndDispatch(wall_slot);
}
}

/// Caller must hold `self.mutex`.
fn snapshotSlotListenersLocked(self: *EventClock) void {
self.slot_snapshot.clearRetainingCapacity();
const limit = @min(self.slot_listeners.items.len, self.slot_snapshot.capacity);
for (self.slot_listeners.items[0..limit]) |listener| {
self.slot_snapshot.appendAssumeCapacity(.{
.callback = listener.callback,
.ctx = listener.ctx,
});
}
}

/// Caller must hold `self.mutex`.
fn snapshotEpochListenersLocked(self: *EventClock) void {
self.epoch_snapshot.clearRetainingCapacity();
const limit = @min(self.epoch_listeners.items.len, self.epoch_snapshot.capacity);
for (self.epoch_listeners.items[0..limit]) |listener| {
self.epoch_snapshot.appendAssumeCapacity(.{
.callback = listener.callback,
.ctx = listener.ctx,
});
}
}

/// Caller must hold `self.mutex`.
fn dispatchWaitersLocked(self: *EventClock, current_slot: ?Slot) void {
const slot = current_slot orelse return;
while (self.waiters.peek()) |head| {
if (head.target > slot) break;
const waiter = self.waiters.pop().?;
waiter.state.aborted = false;
// event.set is thread-safe and does not need the mutex.
waiter.state.event.set(waiter.state.io);
}
}

fn abortAllWaiters(self: *EventClock) void {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
while (self.waiters.pop()) |waiter| {
waiter.state.aborted = true;
waiter.state.event.set(waiter.state.io);
}
}

/// Advance the underlying clock to `target` and dispatch slot/epoch events.
///
/// IMPORTANT: listener callbacks are invoked WHILE holding `self.mutex` to
/// preserve `iter` consistency across slots. Therefore listener callbacks
/// MUST NOT call back into `EventClock` (no `onSlot`, `offSlot`, `onEpoch`,
/// `offEpoch`, `waitForSlot`, `stop`, …) — doing so deadlocks.
fn advanceAndDispatch(self: *EventClock, target: Slot) std.Io.Cancelable!void {
try self.mutex.lock(self.io);
defer self.mutex.unlock(self.io);
var iter = self.clock.advanceTo(target);
while (iter.next()) |event| {
if (self.stopped.load(.acquire)) break;
switch (event) {
.slot => |s| {
self.snapshotSlotListenersLocked();
self.dispatchWaitersLocked(s);
for (self.slot_snapshot.items) |listener| {
listener.callback(listener.ctx, s);
}
},
.epoch => |e| {
self.snapshotEpochListenersLocked();
for (self.epoch_snapshot.items) |listener| {
listener.callback(listener.ctx, e);
}
},
}
}
// Defensive: handles edge cases where advanceTo yields zero events
// (already at target) but waiters were added between loop ticks.
// In the normal case, this is a no-op because the last .slot event
// already dispatched waiters at the same slot value.
self.dispatchWaitersLocked(self.clock.current_slot);
}

fn runAutoLoop(self: *EventClock) void {
while (!self.stopped.load(.acquire)) {
const now_ms = self.clock.time.nowMs();
// Config validation guarantees sec→ms won't overflow, so null here
// indicates a logic bug. Break instead of spinning at 1ms.
const next_ms = slot_math.msUntilNextSlot(self.clock.config, now_ms) orelse {
std.log.err("EventClock: msUntilNextSlot returned null (config overflow?), stopping loop", .{});
self.stop();
break;
};
const sleep_ms = std.math.cast(i64, @max(@as(u64, 1), next_ms)) orelse std.math.maxInt(i64);

// Sleep failure: cancellation (from join()) exits the loop;
// other errors re-check the stopped flag.
std.Io.sleep(
self.io,
std.Io.Duration.fromMilliseconds(sleep_ms),
.awake,
) catch |err| {
if (err == error.Canceled) break;
std.log.debug("EventClock: sleep failed ({s}), retrying", .{@errorName(err)});
continue;
};

if (self.stopped.load(.acquire)) break;
// Only advance after genesis. Before genesis currentSlot() returns
// null — skipping here prevents emitting slot 0 prematurely.
if (self.clock.currentSlot()) |slot| {
self.advanceAndDispatch(slot) catch |err| switch (err) {
error.Canceled => break,
};
}
}
}

fn waitForSlotFutureAwait(state: *WaitState) Error!void {
// NOTE: Do NOT free state here. The caller (WaitForSlotResult.await) frees
// it AFTER this future completes — workaround for Zig futex use-after-free
// where GCD still holds a reference to the event address after wake.
state.event.waitUncancelable(state.io);
if (state.aborted) return error.Aborted;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The assertion density in this file is significantly below the required minimum of two assertions per function (Line 54). Critical functions like init, start, stop, and advanceAndDispatch should be infused with assertions to detect programmer errors and enforce invariants, such as checking that the clock is not already stopped when starting or that the target slot is valid.

References
  1. The assertion density of the code must average a minimum of two assertions per function. (link)

Comment thread src/clock/EventClock.zig Outdated
Comment on lines +88 to +94
next_listener_id: ListenerId = 1,
slot_listeners: std.ArrayListUnmanaged(SlotListenerEntry) = .empty,
epoch_listeners: std.ArrayListUnmanaged(EpochListenerEntry) = .empty,
slot_snapshot: std.ArrayListUnmanaged(SlotSnapshot) = .empty,
epoch_snapshot: std.ArrayListUnmanaged(EpochSnapshot) = .empty,

waiters: WaiterQueue,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The repository style guide mandates putting a limit on everything, specifically mentioning that all loops and all queues must have a fixed upper bound to prevent infinite loops or tail latency spikes (Line 38-39). The waiters priority queue and the slot_listeners/epoch_listeners array lists currently have no fixed capacity limits, which could lead to memory exhaustion or latency spikes under load.

References
  1. Put a limit on everything because, in reality, this is what we expect—everything has a limit. For example, all loops and all queues must have a fixed upper bound. (link)

Comment thread src/clock/EventClock.zig Outdated
.clock = undefined,
.waiters = WaiterQueue.initContext({}),
};
self.clock = SlotClock.init(config, .{ .real = .{ .io = io_handle } }) catch return error.InvalidConfig;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The catch return error.InvalidConfig is redundant here since SlotClock.init returns error{InvalidConfig} and EventClock.init already includes InvalidConfig in its error set. Using try is more idiomatic and concise.

    self.clock = try SlotClock.init(config, .{ .real = .{ .io = io_handle } });

GrapeBaBa added 9 commits May 8, 2026 15:38
Addresses wemeetagain's review on the previous PR — lodestar TS has
deprecated `seconds_per_slot` in favor of `slot_duration_ms` ahead of
the h-fork move to 6-second slots. Storing duration in ms also keeps
sub-second-granularity slot durations representable.

Changes:
- `Config.seconds_per_slot: u64` → `Config.slot_duration_ms: u64`
- `slotDurationMs()` no longer optional — returns `u64`, eliminates
  the sec→ms overflow path
- `slotAtMs` / `msUntilNextSlot` divide by `slot_duration_ms` directly
- `slotAtSec` / `slotStartSec` go through ms internally for consistency
- `validate()` drops the `secToMs(seconds_per_slot)` overflow check
  (no longer applicable)

All test fixtures updated; tests still pass on macOS and Linux.
Two P2 findings on the replanted branch:

1. Gossip disparity boundary was strict `<` — a block/sidecar arriving
   exactly `maximum_gossip_clock_disparity_ms` early was rejected as
   "next slot, not yet". TS lodestar's gossip future check accepts
   equality, so we should too. Changed both `<` sites in SlotClock to
   `<=` (`currentSlotWithGossipDisparity` and
   `isCurrentSlotGivenGossipDisparity`). Existing test that asserted
   the old strict behavior updated.

2. Listener-vs-waiter dispatch order. Under `std.Io.Threaded`, the
   waiter task wakes on a different OS thread, so `event.set` racing
   with listener callbacks meant `waitForSlot(...).await()` could
   return before listeners had finished mutating their state — a test
   reading `trace.slot_len` right after await could observe the old
   value. Invoke listener callbacks BEFORE `dispatchWaitersLocked` so
   await sees the post-callback world.

Both pass on macOS arm64 + Linux x86_64.
Addresses the second half of wemeetagain's review on PR #301: TS
lodestar is preparing for EIP-7782 (consensus-specs#4484, anticipated
in h-fork) which switches from 12-second to 6-second slots mid-chain.
A single `slot_duration_ms` can't express that.

Replaces `Config.slot_duration_ms: u64` with
`Config.slot_durations: []const SlotDuration`, where each `SlotDuration`
records the first slot at which a duration applies. The first entry must
have `start_slot = 0`. Forks that change duration append an entry whose
`start_slot = fork_epoch * slots_per_epoch`.

`Config.constantDuration(genesis, ms, slots_per_epoch)` is the
single-segment convenience for chains without any duration transition
(borrows a static-lifetime schedule slice). `Config.slotDurationMsAt(s)`
walks the schedule backwards.

`slotAtMs` / `slotStartMs` / `msUntilNextSlot` walk the schedule
cumulatively, summing per-segment ms until they find the segment that
contains the timestamp / slot. Validation enforces non-empty schedule,
first entry at slot 0, ascending `start_slot`, and non-zero durations.

New tests cover an EIP-7782-shape config (12s slots up to slot 1024,
6s thereafter) and exercise `slotDurationMsAt`, `slotStartMs`,
`slotAtMs`, and `msUntilNextSlot` across the boundary.

`SlotClock` and `EventClock` test fixtures migrated to the schedule
form. Module API itself is unchanged — `slot_durations` is the only
config-shape change.
Aligns 1:1 with lodestar TS ChainConfig fields after EIP-7782
scaffolding (lodekeeper/lodestar@44a4048c):

  SLOT_DURATION_MS         → slot_duration_ms
  SLOT_DURATION_MS_EIP7782 → slot_duration_ms_after_fork
  EIP7782_FORK_EPOCH * SLOTS_PER_EPOCH → fork_slot

Both `fork_slot` and `slot_duration_ms_after_fork` default to null;
`validate()` requires both set or both null.

Compared to the previous schedule-slice form (`b2806e48`):
- TS field-mapped 1:1 (caller doesn't need to assemble a slice)
- No slice ownership / static-lifetime concern
- Math is simpler — one if branch instead of segment walk
- Trade-off: hard-coded for ONE fork transition. EIP-7782 is the only
  slot-duration change on the roadmap; if a third transition ever
  comes we'll refactor then. TS hasn't even shipped the math for the
  EIP-7782 transition yet.

Tests retained: EIP-7782-shape config covers `slotDurationMsAt`,
`slotStartMs`, `slotAtMs`, `msUntilNextSlot` across the boundary.
…lice

Switch from the 2-field "fork before/after" form to a primary
`slot_duration_ms` plus a default-empty `duration_transitions`
slice. Best of both worlds:

- `slot_duration_ms` maps 1:1 to TS `ChainConfig.SLOT_DURATION_MS`
  — the primary value for chains with no slot-duration change
  (Ethereum mainnet today). Default-empty `duration_transitions`
  means simple chains have a clean Config without optional fields.
- One transition (EIP-7782): one entry in the slice; the entry
  corresponds to TS `(EIP7782_FORK_EPOCH * SLOTS_PER_EPOCH,
  SLOT_DURATION_MS_EIP7782)`.
- N transitions in a hypothetical future: just append entries —
  no API break, no `fork_slot_2` ladder, no breaking refactor.

Math walks segments cumulatively the same way the schedule form
did, but the first segment's duration comes from
`slot_duration_ms` instead of being the first array entry. Validation
enforces sorted ascending `from_slot`, non-zero durations, and that
`from_slot != 0` (would conflict with `slot_duration_ms`).

Adds a `two_fork` test config to exercise the N-transition path
across both boundaries, alongside the existing EIP-7782-shape config
covering one transition.

`SlotClock` / `EventClock` test fixtures unchanged — they all use
the default empty `duration_transitions`.
Slot duration is a fundamental chain parameter that's expected to
change rarely (Ethereum has had zero changes since beacon chain
genesis; EIP-7782 anticipates one). Replaces the
`[]const DurationTransition` slice with an inline
`[max_duration_transitions]DurationTransition` array (cap = 4).

Eliminates the slice ownership / lifetime concern: Config is now
fully value-typed, copyable, and embeddable in any owning struct
without lifetime gymnastics.

`from_slot == 0` doubles as the "unused entry" sentinel — already
rejected by `validate()` for active entries. `Config.transitions()`
returns the active prefix as a slice for callers that want to
iterate. `forkTransitions(comptime list)` is a comptime-friendly
builder that pads trailing slots with sentinels.

Construction reads the same:
```zig
.duration_transitions = forkTransitions(&.{
    .{ .from_slot = 1024, .new_duration_ms = 6_000 },
}),
```

Validation extended to reject "active entry after sentinel" (gap
in the inline array) — sentinels must be a contiguous trailing run.
Codex review caught that `std.Io.async` is allowed to fall back to
inline execution in a few cases — Threaded backend's `busy_count >=
async_limit`, single_threaded builds, OOM, or `Thread.spawn` failure.
If `waitForSlotFutureAwait` runs inline, it blocks on
`state.event.waitUncancelable(io)`, hanging the caller's thread
before `waitForSlot` even returns the `WaitForSlotResult`.

`std.Io.concurrent` has the stronger guarantee: it always opens a
new worker (fiber on Evented, OS thread on Threaded), or returns
`error.ConcurrencyUnavailable`. Switch to it.

On Evented backends (Dispatch, Uring, Kqueue) the runtime behaviour is
unchanged — both `async` and `concurrent` create a fresh fiber and only
diverge on OOM. On Threaded the behaviour matches what the test fixture
already expects: every waiter gets its own thread up to `concurrent_limit`
(default `.unlimited`).

Adds `ConcurrencyUnavailable` to `Error`. On failure, removes the
freshly-pushed waiter from the queue under `lockUncancelable` (cleanup
must complete) and frees `state` before returning the error.
1. `FakeTime.advanceSlot` was still calling the old
   `Config.slotDurationMs()` (no-arg, returns `?u64`), which the
   `slot_duration_ms` + `duration_transitions` refactor removed.
   It's currently dead code but would fail to compile the moment a
   caller showed up. Switch to the fork-aware `slotDurationMsAt(slot)`
   keyed off the slot containing the current `FakeTime.ms`. Adds a
   test that exercises advance across a fork boundary.

2. `msUntilNextSlot` did `slot + 1` unconditionally. For tight
   configs (e.g. 1ms slots from epoch 0) at `now_ms = maxInt(u64)`,
   `slotAtMs` returns `maxInt(Slot)` and the unchecked add overflowed
   — Debug panics, ReleaseFast wraps. The function's contract is
   "Returns null only on arithmetic overflow", so use checked
   `std.math.add(u64, slot, 1)` and propagate `null`. Adds a test at
   the boundary.
`zig build test:clock` walks tests reachable from `src/clock/root.zig`
via `test {}` block references. The block already pulled `slot_math`,
`SlotClock`, `EventClock` but missed `time_source`, so the
`FakeTime.advanceSlot` test added in 90b9bea never actually ran.

Adds `_ = time_source;` so the suite covers it. Test count goes from
37 to 38; `advanceSlot uses fork-aware duration` now runs as case 12/38.
@GrapeBaBa

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08ea0840e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/clock/EventClock.zig Outdated
Comment on lines +495 to +496
for (self.slot_snapshot.items) |listener| {
listener.callback(listener.ctx, s);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid calling listeners while holding EventClock mutex

advanceAndDispatch invokes listener callbacks while self.mutex is still locked, and those callbacks can legally call public APIs like offSlot, offEpoch, waitForSlot, or stop that try to lock the same mutex again. Because this mutex is non-reentrant, that pattern deadlocks the clock loop and can permanently stall event delivery in production whenever a listener performs common actions such as self-unsubscription or scheduling another wait.

Useful? React with 👍 / 👎.

Comment thread src/clock/EventClock.zig Outdated
// `Threaded` busy_count >= async_limit), which would block the caller's
// thread inside `event.waitUncancelable` and never return the
// `WaitForSlotResult`.
const future = std.Io.concurrent(self.io, waitForSlotFutureAwait, .{state}) catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide fallback when concurrent task creation is unavailable

waitForSlot hard-depends on std.Io.concurrent, so on Io implementations/build modes without task-level concurrency this path returns error.ConcurrencyUnavailable and the core waiting API becomes unusable. Since waitForSlot is a primary feature of this module, lacking a non-concurrent fallback means callers can fail at runtime purely due backend choice rather than slot state.

Useful? React with 👍 / 👎.

GrapeBaBa added 12 commits May 9, 2026 10:39
Codex P1 finding: `advanceAndDispatch` previously held `self.mutex` while
calling listener callbacks, deadlocking any callback that re-entered
public APIs (`offSlot`, `waitForSlot`, …) — which is a common pattern
(self-unsubscription, scheduling new waits).

Restructure so per event the dispatcher:
  1. Snapshots listener list under mutex (snapshot helper now grows the
     buffer itself, so onSlot/onEpoch no longer touch `slot_snapshot`/
     `epoch_snapshot` and cannot realloc them concurrently with iteration)
  2. Wakes matching waiters under mutex
  3. Releases mutex
  4. Invokes callbacks (re-entry into public APIs is now safe)
  5. Re-acquires mutex for the next iter step

`iter` is kept alive across the unlock/relock boundary to preserve its
internal `pending_epoch` cursor.

Add `dispatching: atomic.Value(bool)` to serialize `advanceAndDispatch`
across the unlock/relock boundary: the shared snapshot buffer has a
single owner, and callback-driven re-entry (callback → `currentSlot` →
`catchUp` → here) returns immediately instead of recursing.

Snapshot grow at dispatch time can now OOM, so `advanceAndDispatch` /
`catchUp` / read APIs (`currentSlot`, `currentEpoch`, …) widen from
`std.Io.Cancelable!T` to `Error!T`. `runAutoLoop` logs and continues on
OOM, breaks on Canceled, logs+stops on the impossible-but-exhaustive
remainder.
Previous version called `iter.next()` (which advances `clock.current_slot`
unrecoverably) BEFORE the snapshot capacity grow. An OOM there would
silently drop the in-flight slot/epoch event — next dispatch's iter
starts past it.

Move the `ensureTotalCapacity` calls into a single
`reserveSnapshotCapacityLocked` invoked at the top of each loop, before
`iter.next()`. OOM there leaves iter un-advanced so the next dispatch
covers the same slot.

Also flip `runAutoLoop`'s OOM handling from log-and-continue to
log-and-stop. A beacon node that can't grow a small listener snapshot is
already failing; quietly retrying every slot tick just floods logs and
hides the real problem. Stop the loop and let the supervising layer
react (matches CLAUDE.md fail-fast policy).
…elable

The previous commit defended against snapshot-OOM mid-iter by reserving
capacity before each `iter.next()`. But `runAutoLoop` already fail-fasts
on OOM (stops the whole clock), so "preserving the in-flight slot" was
empty insurance — once OOM occurs the clock is dead and no slot will
fire either way.

Replace the explicit OOM error path with a panic. A beacon node that
can't allocate ~16 bytes per listener has process-level problems that
the supervising layer should resolve via restart, not something this
function should plumb through every read API.

Removes:
  - `reserveSnapshotCapacityLocked` helper
  - `…AssumeCapacity` naming on snapshot helpers
  - `Error!T` widening on `currentSlot` / `currentEpoch` /
    `currentSlotOrGenesis` / `currentEpochOrGenesis` /
    `currentSlotWithGossipDisparity` / `isCurrentSlotGivenGossipDisparity`
  - OOM `else` branch in `runAutoLoop`

Net −19 lines; read APIs are back to `Cancelable!T` matching the
pre-replant signatures.
The previous commit panicked on snapshot grow OOM. Switch to returning
`error.OutOfMemory` instead — matches Zig stdlib idiom (allocator
failure is a value, not a panic), is testable via `FailingAllocator`,
and lets `runAutoLoop` log the failure cleanly before stopping the
clock. Net effect for callers is the same (clock dies, supervisor
restarts), but the failure mode is observable.

No `reserveSnapshotCapacityLocked` is reintroduced. OOM happens AFTER
`iter.next()` advanced the clock past the in-flight slot, so the
slot's listeners do not fire — but `runAutoLoop` stops the clock
immediately after, and once stopped no further slots fire either.
A single missed slot during shutdown does not justify the
pre-reservation complexity.

Read APIs (`currentSlot`, `currentEpoch`, `currentSlotOrGenesis`,
`currentEpochOrGenesis`, `currentSlotWithGossipDisparity`,
`isCurrentSlotGivenGossipDisparity`) widen from `Cancelable!T` to
`Error!T`. `runAutoLoop` gains an `else` branch that logs and stops
on any non-`Canceled` failure.
…rget preservation, start spawn)

Four issues raised in the latest codex review of the clock-replant
branch:

P1 — Wake waiters AFTER listener callbacks (correctness, Threaded race).
  Previously `dispatchWaitersLocked(s)` ran inside the mutex BEFORE the
  unlock+callback step. Under `std.Io.Threaded`, the event.set() resumed
  a `waitForSlot(...).await()` on another thread, which could observe
  state listeners hadn't yet written. Move `dispatchWaitersLocked` to
  AFTER the relock that follows the callback loop, so the relock's
  release-acquire ordering covers listener-written state.

P2 — Dispatch before sleeping in `runAutoLoop`.
  Previous order was compute-sleep-dispatch. If a dispatch ran past a
  slot boundary, the next loop iteration computed a tiny msUntilNextSlot
  to the boundary AFTER the elapsed slot, sleeping through the slot's
  events. Reorder to dispatch-compute-sleep so any time elapsed during
  the prior callback batch fires immediately.

P3 — Pick up wall-clock drift at end of dispatch loop.
  When concurrent caller B short-circuits via the `dispatching` CAS, B's
  desired (higher) target was lost. Wrap the iter loop in an outer loop
  that re-reads `clock.currentSlot()` after the inner exits; if wall-
  clock advanced past `current_target` (because callbacks were slow OR
  because a concurrent caller wanted a higher target), bump the target
  and re-iter. iter.next() returns null only after draining its
  pending_epoch cursor, so recreating iter at the outer-loop boundary
  cannot drop a queued epoch event.

P4 — `start()` uses `concurrent`, returns Error!void.
  `std.Io.async` is allowed to run inline on backends that can't spawn
  more work (single-threaded, busy_count >= async_limit, OOM); that
  would block `start()` indefinitely inside `runAutoLoop`. Switch to
  `std.Io.concurrent` (mirrors the prior P1 fix on `waitForSlot`) and
  surface `error.ConcurrencyUnavailable`. All 6 in-tree tests that call
  `clock.start()` now `try` it.
…re sleep)

P2-A — `WaitForSlotResult.cancel()` raced with the dispatcher /
`abortAllWaiters()`: it wrote `state.aborted = true` outside the mutex
even when the waiter had already been popped under that mutex by
another path. On `std.Io.Threaded` this was a data race against the
fiber's read of `state.aborted` after wake, AND could turn a
successfully reached slot into `error.Aborted`. Fix: only write the
flag while we still own the popped entry inside the critical section.
If we don't find ourselves in `waiters`, the dispatcher (or
`abortAllWaiters`) already wrote the flag and signaled the event, so
just await and free.

P2-B — `runAutoLoop` slept on `.awake` which excludes suspended host
time. Combined with `now_ms`/slot_math running on wall-clock, a host
suspend left the loop sleeping its remaining pre-suspend duration
after resume — slot dispatch lagged real chain time by however long
the suspend lasted. Switch to `.boot`, which is monotonic but counts
suspend, so the sleep wakes promptly after resume and we catch up via
the existing wall-clock recheck loop in `advanceAndDispatch`.
…eep boundary)

P2 — `waitForSlot` fast-path raced with in-flight slot callbacks.
  When a dispatch was running outside the mutex (snapshot taken,
  callbacks executing), `iter.next()` had already advanced
  `clock.current_slot` to (or past) `target`. A concurrent
  `waitForSlot(target)` would acquire the mutex during the callback
  window, hit `slot >= target`, and return `.immediate` — but the
  listeners for `target` hadn't finished writing the state the
  awaiter then read. Gate the fast-path on `dispatching == false`.
  When dispatch is in-flight, queue the waiter; the dispatcher's
  per-slot `dispatchWaitersLocked` (which now runs after the callback
  batch under the relock) wakes it in the right order.

P2 — `runAutoLoop` could sleep through a just-started slot.
  Previously the sleep was computed from `msUntilNextSlot(now_ms)`,
  which uses wall-clock now to pick the upcoming boundary. If the
  wall crossed a boundary between `advanceAndDispatch()` returning
  and this calculation, the helper returned the time to the boundary
  AFTER the just-started slot — sleeping a full slot and delaying
  that slot's listener/waiter dispatch. Compute the sleep from the
  cached next undispatched slot's start time
  (`slotStartMs(config, current_slot + 1)`) and skip the sleep if
  that timestamp is already in the past.
…-fiber model

Pivot the threading model: target `std.Io.Evented` (single OS thread,
cooperative fibers) instead of trying to be safe under
`std.Io.Threaded`. Public methods are called from one execution flow;
the internal `runAutoLoop` is its own fiber on the same OS thread, so
cooperative scheduling provides mutual exclusion at non-yield points
without any explicit lock.

Removes the entire complexity stack that grew defending the multi-
threaded mutex model:

- `mutex: std.Io.Mutex` field + every lock/unlock/lockUncancelable call
- `dispatching: atomic.Value(bool)` + the CAS protocol that serialized
  concurrent dispatchers
- snapshot-listeners-under-lock + invoke-callbacks-after-unlock dance
- The outer wall-clock recheck loop in `advanceAndDispatch`
- `Error!T` widening on read APIs (`currentSlot`, `currentEpoch`, ...)
  that came from `mutex.lock`'s `Cancelable` plus `OutOfMemory` from
  snapshot grow
- `error.ConcurrencyUnavailable` from `std.Io.concurrent` on `start()`
  (back to `std.Io.async` returning void)
- `stopped: atomic.Value(bool)` (back to plain bool)

Restores TS-equivalent semantics:

- Listener callbacks run synchronously on the dispatching fiber under
  `emitSlot` / `emitEpoch`, like `EventEmitter.emit` in TS
- `currentSlot()` etc. are non-fallible; they `catchUp` synchronously
  before returning, matching `get currentSlot()` triggering emit
- `waitForSlot` keeps the future API; `std.Io.async` is sufficient
  because under Evented the future's awaited fiber runs cooperatively
  with `runAutoLoop`'s fiber that signals it

Documented contract: callbacks must not call back into `EventClock`
methods (mirrors TS where re-entering the `currentSlot` getter would
recurse `emitEvents`). Tests still use `std.Io.Threaded` for now —
will revisit when stdlib `Io.Dispatch` (macOS) and `Io.Uring` (Linux)
bugs blocking Evented in 0.16 are addressed.

Net −172 lines (1067 → 895 in EventClock.zig).
Codex flagged that under our single-fiber contract, three issues remain
that don't depend on the threading model:

- Tests still instantiated `std.Io.Threaded`, contradicting the
  documented Evented-only model and racing on shared state.
- `runAutoLoop` slept first then dispatched, so a slow callback could
  push the next slot's events to the boundary AFTER the elapsed slot.
- `.awake` clock excludes suspended host time even though slot math
  runs on wall-clock, so a suspend would leave the loop behind real
  chain time.

Fixes:
- `TestIo` switches from `std.Io.Threaded` to `std.Io.Evented` —
  Linux uses Uring, macOS uses Dispatch. NOTE: Zig 0.16.0 stdlib has
  several bugs in both backends (`Io/Uring.zig` missing
  `ReadOnlyFileSystem` in error sets, `Io/Dispatch.zig` comptime
  assertion in `deinit`); upstream patches needed before this builds
  cleanly out of the box.
- `runAutoLoop` reordered to dispatch-then-sleep, with sleep computed
  against `current_slot + 1`'s boundary rather than wall-clock now (so
  a wall-jump between dispatch and sleep doesn't skip a slot).
- Sleep clock changed from `.awake` to `.boot` so suspended host time
  counts.

Codex's fourth finding (P1: `std.Io.async` may run inline) does not
apply under the Evented-only contract — `async` returns once the
function yields, which `runAutoLoop` and `waitForSlotFutureAwait` both
do at their first I/O wait. The `concurrent`/`ConcurrencyUnavailable`
machinery is not needed.
Match the original PR #301 shape that the user asked to revert to:

- `WaitForSlotResult` reverts from tagged union (`.immediate` /
  `.pending` switch) to a plain struct that uses
  `Future{.any_future = null, .result = ...}` for the immediate case.
  `Future.await` returns `.result` when `any_future` is null, so
  the callsite is uniform whether the wait was immediate or pending —
  no per-call discriminant.
- `await(io)` now takes the `io` parameter explicitly. Removes the
  `state.io` indirection and the awkwardness of needing `state` to
  exist for an immediate result.
- `cancel()` collapses to an `orelse return` early-out followed by
  the cleanup path; no more two-arm switch.
- `advanceAndDispatch` reverts to module-private `fn` (still callable
  from in-file tests). Was made `pub` only to keep the
  outside-of-test API surface stable through earlier rewrites.
- `runAutoLoop` reverts to the simpler `sleep → dispatch` order with
  `msUntilNextSlot(now_ms)` and `.awake` clock. The codex P2 findings
  (catch-up before sleep, suspend-aware sleep) were strict
  improvements but added complexity that the rest of the lodestar TS
  reference does not have either; matching TS behavior is preferred
  here.
- Doc comment trimmed back to the original short version. Kept the
  `slot_duration_ms` Config and fork-aware schedule in slot_math —
  unrelated to the EventClock surgery.

Tests adjust to call `fut.await(io_handle)` and use
`slot_duration_ms = N_000` instead of `seconds_per_slot = N`.
Comments now describe what the code does instead of pointing back to
the TS lodestar implementation. Affected:

- EventClock.zig listener API note (callback re-entry rules)
- EventClock.zig delegated read APIs section header
- SlotClock.zig gossip-disparity-threshold test
- slot_math.zig Config doc
@GrapeBaBa

Copy link
Copy Markdown
Contributor Author

@codex review

1 similar comment
@GrapeBaBa

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 309d991268

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The clock is the only zio consumer (Runtime.init in the EventClock test
harness). 0.14 carries async cancellation fixes — notably #443
(completion-lifecycle use-after-free under concurrent cancellation on
kqueue) — directly relevant to the clock's cancel/join paths on macOS.

API-compatible (Runtime.init unchanged) and the single-fiber model is
preserved (default RuntimeOptions still resolves to executors = 1).
47/47 test:clock green, including the 500-seed cancel/stop fuzzer.

@twoeths twoeths left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems too much to have a separate src/clock module, maybe have it under src/utils instead?
some upcoming modules to be added are quite big, like src/beacon_node, src/validator

@wemeetagain

Copy link
Copy Markdown
Member

Imo its fine to have small modules if / when it makes sense. (For example we have a hex module that is just a single file!) I also don't really like "utils" as a top-level concept, I think that kind of misc grab-bag is lazy organization and should be avoided if possible.

@GrapeBaBa

Copy link
Copy Markdown
Contributor Author

@nazarhussain could you review again, addressed your comments and make time_source generic

Comment thread src/clock/time_source.zig Outdated
@@ -0,0 +1,64 @@
//! Pluggable time source so tests can inject deterministic time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a bit strange to me because std.Io is already an interface which might have a mocked time implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to use a fake clock io for testing, it looks like more cleaner

@spiral-ladder

spiral-ladder commented Jun 29, 2026

Copy link
Copy Markdown
Member

seems too much to have a separate src/clock module, maybe have it under src/utils instead? some upcoming modules to be added are quite big, like src/beacon_node, src/validator

i'm also against dumping more things into /utils 😬 every fn, every module is technically a utility if we think about things from a util world. Hence #169

there were some efforts to move things out (eg. #322) but dont think we have reached some conclusion yet as a team, but refactoring is not too urgent, we just want to be careful to not add more things into the utils black hole

std.Io is already a mockable interface — its `now` is a vtable entry — so
the separate TimeSource (RealTime/FakeTime) abstraction and the generic
SlotClock.Clock(comptime Time) were a parallel mechanism for something the
interface already provides. SlotClock is now a plain struct holding
`io: std.Io`, reading wall-clock time via std.Io.Clock.real.now; tests
inject a fake-clock std.Io (FakeClockIo) that populates only the `now`
vtable entry (safe because Layer-1 SlotClock is pure-read).

Deletes time_source.zig, removes the generic + the RealTime wrapper, and
simplifies EventClock's field/init. Behavior identical (46/46 test:clock;
the one dropped test only exercised a deleted test-helper — slotDurationMsAt
stays covered in slot_math). Suggested by wemeetagain in the PR review;
unwinds the earlier duck-typing refactor. Reviewed via develop-verify-loop
(zig/architecture/leanness/correctness, converged on a dry round).
Comment thread src/clock/slot_math.zig Outdated
//! Layer 0 – Pure slot/epoch arithmetic.
//!
//! No state, no allocation, no I/O. Every function is comptime-compatible.
//! All overflow paths return `null` (`?T`) instead of panicking.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the motivation here returning nulls instead if error unions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost all the overflow here is effectively unreachable, so I thought it was not worth to pollute a lot of function signatures to return error union. But it is probably we should just use +/- to instead of math.add/math.sub

…for pre-genesis

The slot/time arithmetic guarded overflow with std.math.mul/add/sub/cast
... catch. But overflow is unreachable on real input: now_ms is the wall
clock (can't overflow u64 ms for ~580M years) and slot/config values are
program-controlled (current/duty slots, a validated config) — never
attacker/network-derived (the one gossip entry that takes a message slot,
isCurrentSlotGivenGossipDisparity, only compares it). So an overflow could
only come from an absurd config/clock, where a trap is the correct fail-fast.

slot_math: plain +/*/-; drop the defensive optionals — slotStartMs /
slotStartSec / msUntilNextSlot / secToMs -> u64. The only remaining null is
pre-genesis (a < comparison) on slotAtMs / slotAtSec.

SlotClock / EventClock: plainify the remaining guards. slotWithFutureToleranceMs
keeps ?Slot (pre-genesis); slotWithPastToleranceMs -> Slot (the underflow null
is gone, orelse 0 always yields a slot). EventClock sleep_ms -> plain @intcast.
secFromSlot / msFromSlot drop the i128 intermediate but stay i64: the result is
a signed delta (to - slotStart) that is genuinely negative for a future slot or
an earlier `to` — an ordinary input, so the sign is semantics, not padding.

ClockConfig.validate()'s genesis*1000 check is retained: it is the validator
whose contract is to return error.InvalidConfig, and it is the precondition that
makes the downstream plain genesis*1000 safe.

Reviewed via develop-verify-loop (zig / correctness incl. a DoS input-source
classification + sign-preservation check / leanness — dry round). 46/46
test:clock; behavior identical on every real input.
nowMs / nowSecAt / nowMsAt asserted `x >= 0` before `@intCast(i64 -> u64)` on
a wall-clock value. The assert is redundant: @intcast already traps on a
negative in safe builds (verified across all optimize modes) and is a no-op in
fast builds, and it asserts external wall-clock state. Collapse each body to one
line; the helpers stay (reused 20x / 8x / 4x).

Reviewed via develop-verify-loop (zig + leanness, dry round). 46/46 test:clock.
SlotClock.nowMs, EventClock.nowSecAt, and EventClock.nowMsAt each duplicated
`@intCast(std.Io.Clock.real.now(io).to{Milliseconds,Seconds}())`. Move that into
src/time.zig as nowMs(io)/nowSec(io), alongside the existing monotonic
timestampNow/since (`.awake`) — so all std.Io time access lives in one place,
with the wall-clock (`.real`, for slot math) vs monotonic (`.awake`, for
durations) distinction documented there.

SlotClock.nowMs delegates to time.nowMs; EventClock drops both helpers and uses
time.nowSec/time.nowMs (24 call sites). build.zig.zon adds `time` to the clock
module's imports.

Reviewed via develop-verify-loop (correctness incl. sec-vs-ms + acyclic-dep
checks / leanness — dry round). 46/46 test:clock; behavior identical.
…directly

With time.nowMs(io) available, the one-line `nowMs` method was pure indirection.
Inline it: 8 `self.nowMs()` sites -> `time.nowMs(self.io)`, and the one
EventClock auto-loop site -> `time.nowMs(self.clock.io)`. Behavior identical
(the method body was already `return time.nowMs(self.io)`). 46/46 test:clock.
…>.now

`timestampNow` (the monotonic `.awake` now-reader used for elapsed-duration
timing) reads better paired with `since` as a start/since timer idiom:
`const s = time.start(io); ... time.since(io, s)`. Rename it to `start` across
its ~48 call sites (bench + state_transition + fork_choice); since()'s `start`
parameter is renamed to `from` to avoid shadowing the new function.

Also unify the std-API form: `start` now uses `std.Io.Clock.awake.now(io)` to
match nowMs/nowSec's `std.Io.Clock.real.now(io)` — byte-identical dispatch (both
-> io.vtable.now(.., clock)), but the clock variant (.awake/.real) is now visible
in the call path across all three readers.

Reviewed via develop-verify-loop (dry round). test:clock 46/46,
test:state_transition 96/96, test:fork_choice 173/173; behavior identical.
Three std.math.maxInt(Slot) guards were over-defensive:
- AdvanceIterator: `if (cur == maxInt) return null` is dead code — the preceding
  `cur >= self.target` already returns null whenever cur == maxInt.
- currentSlotWithGossipDisparity / isCurrentSlotGivenGossipDisparity: the
  `current == maxInt` guards on `current + 1` only fire for a wall clock ~580M
  years out; `current + 1` is now plain.

secToMs was a trivial `sec * 1000` leftover from its old overflow check (since
plainified); inlined at its 4 sites and deleted.

TS beacon clock (JS number, no integer overflow) carries no such guards and
computes genesisTime*1000 inline, so this converges toward TS, not away.
Reviewed via develop-verify-loop (dry round). 46/46 test:clock; behavior
identical on real input.
…spatch

The `dispatchWaiters(current_slot)` after the advance loop was over-defensive
dead code. Queued waiters always have target > current_slot (waitForSlot only
enqueues when target > current_slot, after catchUp + early-return), and the
in-loop per-slot dispatch resolves each waiter the instant current_slot reaches
it; the stop() path drains via abortAllWaiters. So the trailing call could never
pop anything. Its comment narrated a false "waiters added between loop ticks"
premise — impossible in this single-fiber module (advanceAndDispatch has no
yield point).

TS waitForSlot is Promise+listener with no equivalent trailing dispatch, so
removal improves parity. Found by a develop-verify-loop over-defense + TS-parity
audit; a design gate proved the call dead (overriding the brief's "keep");
the 500-seed property fuzzer + reentrancy/catch-up tests confirm no stranded
waiter. 46/46 test:clock.
zio's RuntimeOptions defaults to enable_main_executor=true, so the runtime's
main executor runs on the calling (test) thread. Calling the EventClock async
ops (std.Io.concurrent in start/waitForSlot, fut.await) directly then drives
them inline via the main task's block-in-place — the explicit rt.spawn(body)
+ handle.join() wrapped the body in a redundant second fiber. This is zio's
own test idiom (async ops called directly after Runtime.init; see zio
fs.zig/net.zig).

Bonus: test failures/panics now report on the main-thread stack instead of
unwinding inside a joined fiber. Verified via develop-verify-loop; 46/46
test:clock, no hang.
With runInRuntime reduced to Runtime.init + body(rt.io()), the helper only
forced a `struct { fn run(io) !void {…} }.run` closure on every test. Inline the
3-line setup (Runtime.init + defer deinit + io_handle = rt.io()) into each of the
20 tests and delete the helper — dropping the closure boilerplate and a nesting
level so each test body reads directly.

A whitespace-ignoring diff confirms the only non-indentation changes are the 20
wrapper->setup swaps + the helper deletion; no test body logic changed. 46/46
test:clock, no hang.
@wemeetagain
wemeetagain merged commit 385b077 into main Jun 30, 2026
30 checks passed
GrapeBaBa added a commit that referenced this pull request Jun 30, 2026
Resolve append-list conflicts in build.zig.zon (clock + beacon_node test
modules) and CI.yml (clock + beacon-node test steps) after the clock module
(#354) merged to main.
@nazarhussain

nazarhussain commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

One of my major design concern on the design was the separation between EventClock and SlotClock which makes it difficult to reason about the clock which we used to have. The SlotClock mostly about mathematics can be moved to slot math, so we end of up with one user facing object of Clock.

Created one issue for it. #457

@github-actions github-actions Bot mentioned this pull request Jul 30, 2026
@wemeetagain
wemeetagain deleted the gr/clock-replant branch August 17, 2026 19:39
wemeetagain pushed a commit that referenced this pull request Aug 19, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.0.0](v0.1.2...v1.0.0)
(2026-08-19)


### Features

* add `state.getBuildersLength()` binding
([#472](#472))
([be2b5ab](be2b5ab))
* **beacon-node:** add block state cache and checkpoint datastore
([#452](#452))
([2145faa](2145faa))
* bindings to `getExpectedWithdrawals` and native tweaks
([#350](#350))
([f47bc66](f47bc66))
* **bindings:** add pubkey cache syncPubkeys
([#537](#537))
([542779f](542779f))
* **bindings:** aggregate cached public keys by validator index
([#397](#397))
([2f90603](2f90603))
* **bindings:** align `BeaconStateView` with `IBeaconStateView`
([#347](#347))
([b8ec273](b8ec273))
* **bindings:** configurable pubkey cache growth step
([#481](#481))
([133ef24](133ef24))
* **bindings:** expose more APIs for STF
([#444](#444))
([7fe2609](7fe2609))
* **bls:** add small MSM for npoints &lt; 32
([#393](#393))
([b430638](b430638))
* **blst:** use external buffers for blst operations
([#358](#358))
([78e4678](78e4678))
* **ci:** conditionally publish bindings with tag
([#355](#355))
([ea77919](ea77919))
* **clock:** add clock module for slot/epoch timing
([#354](#354))
([385b077](385b077))
* **fork_choice:** add Prometheus metrics module
([#309](#309))
([cbc9d8d](cbc9d8d))
* **forkchoice:** implement the forkchoice module
([#246](#246))
([7c62a9b](7c62a9b))
* getSyncCommitteesWitness
([#367](#367))
([ef77649](ef77649))
* implement `loadState` API and binding
([#165](#165))
([f903519](f903519)),
closes [#159](#159)
* **metrics:** metrics bindings
([#455](#455))
([dd41999](dd41999))
* migrate blst,pubkeys to use zapi js dsl
([#331](#331))
([fcd26ca](fcd26ca))
* **pubkeys:** add getPubkeyBytes binding
([#555](#555))
([4ca51cf](4ca51cf))
* publish ARM64 musl bindings
([#482](#482))
([ac764c9](ac764c9))
* **shuffle:** add swap-or-not shuffling module and binding
([#559](#559))
([c2db37c](c2db37c))
* split nextValue fn
([#464](#464))
([b47faeb](b47faeb))
* support getLatestWeakSubjectivityCheckpointEpoch
([#366](#366))
([dcf3883](dcf3883))
* update fulu deposit processing
([#442](#442))
([064335c](064335c))


### Bug Fixes

* avoid set ([#484](#484))
([2e25d97](2e25d97))
* better generation of rand scalar
([#388](#388))
([74dce77](74dce77))
* **bindings:** accept `dontTransferCache` in processSlots for backward
compatibility
([#460](#460))
([65df5af](65df5af))
* **bindings:** check signature infinity by default
([#509](#509))
([2f5f281](2f5f281))
* **bindings:** clean up failed async BLS work
([#527](#527))
([1111b00](1111b00))
* **bindings:** free metrics writer on scrape failure
([#529](#529))
([4c8d94a](4c8d94a))
* **bindings:** harden random aggregate scalars
([#528](#528))
([8e89a63](8e89a63))
* **bindings:** log level for missing fields
([#435](#435))
([08faf41](08faf41))
* **bindings:** misordering of print for cpu count
([#381](#381))
([752a972](752a972))
* **bindings:** populate epoch participation for test fixtures
([#436](#436))
([8dbdd2e](8dbdd2e))
* **bindings:** refcount Pool to fix teardown panic
([#352](#352))
([23b2f68](23b2f68))
* **bindings:** roll back partial N-API initialization
([#491](#491))
([31c5ebb](31c5ebb))
* **bindings:** size BLS thread pool by cgroup-aware CPU count
([#386](#386))
([3ae9522](3ae9522))
* **bindings:** validate class types before unwrap
([#514](#514))
([2fd2ad5](2fd2ad5))
* **bindings:** validate secret key hex length
([#517](#517))
([136e415](136e415))
* **bls:** align PublicKey.uncompress validation with
Signature.uncompress
([#508](#508))
([5a8dbe9](5a8dbe9))
* **bls:** bound randomized aggregation inputs
([#548](#548))
([779d0bf](779d0bf)),
closes [#542](#542)
* **bls:** clean up partial thread pool initialization
([#490](#490))
([d55e598](d55e598))
* **bls:** convert pippenger scratch bytes to element counts
([#513](#513))
([a12ca92](a12ca92))
* **bls:** enforce 32-byte signing roots
([#545](#545))
([72fd308](72fd308))
* **bls:** make batch cardinality structural
([#547](#547))
([a06d8b2](a06d8b2))
* **bls:** preserve aggregate outputs on failure
([#521](#521))
([e0b6dd1](e0b6dd1))
* **bls:** reject empty keygen salts
([#524](#524))
([d2a9c86](d2a9c86))
* **bls:** reject unknown BLST error codes
([#525](#525))
([9e4a6ad](9e4a6ad))
* **bls:** size pairing buffers for 32-bit targets
([#531](#531))
([dc64a27](dc64a27))
* **blst:** default signature infinity check to true if not provided
([#387](#387))
([021cdcb](021cdcb))
* **build:** remove `zig-out` from `files`
([#360](#360))
([c52af09](c52af09))
* **ci:** fix caching spec test version
([#439](#439))
([96885a1](96885a1))
* dangling state pointer in loadOtherState
([#450](#450))
([81cbd5f](81cbd5f))
* **epoch_cache:** compute missing `next_proposers`
([#447](#447))
([0088a29](0088a29))
* **epoch_cache:** populate decision roots in afterProcessEpoch
([#453](#453))
([4b70a5e](4b70a5e))
* export asyncAggregateWithRandomness through napi binding
([#371](#371))
([1d04c2b](1d04c2b))
* harden memory safety across PMT, SSZ tree views, and state transition
([#377](#377))
([d6f5897](d6f5897))
* improve atomic ordering in ThreadPool and NAPI init
([#310](#310))
([4b0a1cc](4b0a1cc))
* interface compatbility with NativeBeaconStateView
([#445](#445))
([89e13d1](89e13d1))
* missing deinits in loadOtherState
([#459](#459))
([094d278](094d278))
* missing state commits
([#454](#454))
([a432b55](a432b55))
* no-op when syncPubkeys run on a pk cache with shrinking validator set
([#432](#432))
([ed05a99](ed05a99))
* param order in BeaconBlockBody
([#348](#348))
([d8b9c06](d8b9c06))
* pendingConsolidations bindings
([#449](#449))
([b9c497e](b9c497e))
* **pmt,ssz:** harden chunked-leaf and zero-copy tree-view memory safety
([#400](#400))
([de50c53](de50c53))
* populate cache balances during rewards/penalties processing
([#474](#474))
([5bf23dc](5bf23dc))
* re-expose sizes
([#369](#369))
([64b81f3](64b81f3))
* remove `slashValidator` gating on active status
([#448](#448))
([d319a0d](d319a0d))
* **ssz:** drop redundant default-init pass in fixed-list decode
([#468](#468))
([0c757be](0c757be))
* **ssz:** publish child cache entries after lookup
([#565](#565))
([21e78c9](21e78c9))
* state transition binding exports
([#456](#456))
([895982c](895982c))
* **state-transition:** group-check signature sets
([#515](#515))
([42774e9](42774e9)),
closes [#502](#502)
* **state-transition:** isolate epoch step cache mutations
([#535](#535))
([a83741a](a83741a))
* **state-transition:** repair Pool.init call broken by
[#346](https://github.com/ChainSafe/lodestar-z/issues/346)×[#367](https://github.com/ChainSafe/lodestar-z/issues/367)
merge skew ([#394](#394))
([b42944f](b42944f))
* various fixes around config
([#433](#433))
([c4f082c](c4f082c))


### Performance Improvements

* **bindings:** drop TS BLS comparison benches and report benchmarks on
PRs ([#552](#552))
([c909c6f](c909c6f))
* **bls:** add cache-aware signature verifier
([#562](#562))
([063857e](063857e))
* **bls:** bypass worker queue for small batches
([#553](#553))
([3f8a6df](3f8a6df))
* **epoch:** replace AutoHashMap with array lookup in reward/penalty
caches ([#286](#286))
([e4e181b](e4e181b)),
closes [#243](#243)
* **pmt:** chunked-leaf packing for basic lists and container_struct
([#346](#346))
([ba156c4](ba156c4))


### Code Refactoring

* allocate `AsyncAggRandData` in one obj
([#384](#384))
([459750f](459750f))
* **bindings/pubkeys:** simplify allocation strategy for aggregate
([#518](#518))
([b82750f](b82750f))
* **bindings:** rename blst Lifecycle to State
([#516](#516))
([0a9c179](0a9c179))
* **bindings:** use zapi js.io() instead of local io module
([#469](#469))
([2b34cc0](2b34cc0))
* **bindings:** wake only required number of workers
([#383](#383))
([1db57f1](1db57f1))
* **bls:** allocations around VMAS
([#395](#395))
([dfda58c](dfda58c))
* **bls:** clean up bls
([#398](#398))
([e0f3b9b](e0f3b9b))
* **bls:** remove need for tracking results for
verifyMultipleAggregateSignatures
([#389](#389))
([6fe5c3f](6fe5c3f))
* **bls:** remove single-threaded fallback
([#390](#390))
([e057713](e057713))
* **clock:** single public Clock; internalize SlotClock
([#463](#463))
([fbab1fa](fbab1fa))
* make XXXDecisionRoot fns return `js.String`
([#342](#342))
([aef4420](aef4420))
* move shuffle into swap_or_not_shuffle module
([#558](#558))
([e56efb2](e56efb2))
* **pubkeys:** centralize the process-wide cache
([#522](#522))
([dc9669d](dc9669d))


### Miscellaneous Chores

* avoid slow tests in AGENTS.md
([#546](#546))
([c60f2a9](c60f2a9))
* bump zapi to include musl build
([#485](#485))
([0b488cc](0b488cc))
* **ci:** pin github actions with sha hashes
([#507](#507))
([167b8f5](167b8f5))
* deprecate unused blst APIs
([#575](#575))
([7b547fa](7b547fa))
* **deps:** bump zapi v2.1.0 -&gt; v2.2.0
([#376](#376))
([0c240d8](0c240d8))
* **deps:** bump zbuild
([#403](#403))
([e2545de](e2545de))
* **deps:** compile blst with ReleaseFast
([#391](#391))
([753a896](753a896))
* **deps:** update zapi to 3.1.0
([#483](#483))
([f3e5827](f3e5827))
* **deps:** use zapi v2.1.0
([#372](#372))
([88f403a](88f403a))
* disable gemini auto code review
([#382](#382))
([63e42a4](63e42a4)),
closes [#380](#380)
* **docs:** add comments section in AGENTS.md
([#566](#566))
([0c09750](0c09750))
* move state clones out of benchmark run functions
([#324](#324))
([e4035de](e4035de))
* prepare 1.0.0 release
([#576](#576))
([20b657b](20b657b))
* release v0.1.2-rc.3
([#370](#370))
([e4fc551](e4fc551))
* **release:** 0.1.2-rc.2
([#365](#365))
([7046128](7046128))
* **release:** v0.1.2-rc.10
([#477](#477))
([9a4fad5](9a4fad5))
* **release:** v0.1.2-rc.4
([#373](#373))
([09468f1](09468f1))
* **release:** v0.1.2-rc.5
([#374](#374))
([f344efa](f344efa))
* **release:** v0.1.2-rc.6
([#375](#375))
([bdf5b67](bdf5b67))
* **release:** v0.1.2-rc.8
([#401](#401))
([06f91c2](06f91c2))
* **release:** v0.1.2-rc.9
([#404](#404))
([6024800](6024800))
* remove merge transition code
([#359](#359))
([09b175d](09b175d))
* remove stale epoch cache TODOs
([#534](#534))
([27a547a](27a547a))
* rename era shortHistoricalRoot to shortEraRoot
([#473](#473))
([c75a4d3](c75a4d3))
* **scripts:** build bindings with preset
([#434](#434))
([a1b5ef7](a1b5ef7))
* silence debug log when used in release builds
([#486](#486))
([c5377d7](c5377d7))
* support dev workflow
([#364](#364))
([fcb9a78](fcb9a78))
* update gloas types to align with the latest specs
([#431](#431))
([1f065b5](1f065b5))
* update spec test version to v1.7.0-alpha.11
([#451](#451))
([5875660](5875660))
* update spec-test-version: v1.6.0-beta.2 -&gt; v1.7.0-alpha.10
([#441](#441))
([f932b1c](f932b1c))
* update zapi to 4.0.0
([#571](#571))
([de8e3fd](de8e3fd))


### Documentation

* document security threat model
([#557](#557))
([e678b87](e678b87))
* more comprehensive AGENTS.md
([#520](#520))
([c74b386](c74b386))
* **pkix:** document load provenance requirement
([#556](#556))
([37e0aa2](37e0aa2))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants