Skip to content

feat: add clock module for slot/epoch timing (Zig 0.16)#301

Closed
GrapeBaBa wants to merge 4 commits intoChainSafe:chore/zig-masterfrom
GrapeBaBa:GrapeBaBa/add-clock-module
Closed

feat: add clock module for slot/epoch timing (Zig 0.16)#301
GrapeBaBa wants to merge 4 commits intoChainSafe:chore/zig-masterfrom
GrapeBaBa:GrapeBaBa/add-clock-module

Conversation

@GrapeBaBa
Copy link
Copy Markdown
Contributor

@GrapeBaBa GrapeBaBa commented Apr 1, 2026

Summary

  • Adds a new clock module under src/clock/ ported from ChainSafe/clock-zig for Zig 0.16
  • Three-layer beacon clock architecture:
    • Layer 0 (slot_math) – pure arithmetic, comptime-compatible
    • Layer 1 (SlotClock) – stateful clock with pluggable time source
    • Layer 2 (EventClock) – async event loop with listeners and waiters
  • Integrates into build.zig with test:clock step and included in test (run-all)

Three-layer beacon clock architecture:
- Layer 0 (slot_math): pure arithmetic, comptime-compatible
- Layer 1 (SlotClock): stateful clock with pluggable time source
- Layer 2 (EventClock): async event loop with listeners and waiters

Ported from ChainSafe/clock-zig for Zig 0.16.
@GrapeBaBa GrapeBaBa requested a review from a team as a code owner April 1, 2026 03:59
Copilot AI review requested due to automatic review settings April 1, 2026 03:59
@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 introduces a robust, three-layer beacon clock architecture designed for Zig 0.16. By separating pure arithmetic, stateful time tracking, and asynchronous event handling, the new module provides a reliable foundation for slot and epoch timing within the project. The implementation includes comprehensive testing and seamless integration into the existing build system.

Highlights

  • New Clock Module: Introduced a comprehensive clock module under src/clock/ to manage slot and epoch timing, ported for Zig 0.16.
  • Three-Layer Architecture: Implemented a modular design consisting of slot_math (pure arithmetic), SlotClock (stateful clock), and EventClock (async event loop).
  • Build System Integration: Updated build.zig to include the new clock module and added a dedicated test:clock build step.

🧠 New Feature in Public Preview: You can now enable Memory 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 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 counter productive. 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.

Copy link
Copy Markdown
Contributor

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

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 architecture for Ethereum consensus, comprising pure arithmetic (slot_math), stateful tracking (SlotClock), and an async event loop (EventClock). The reviewer identified several violations of the project's safety-first style guide, specifically regarding the lack of mandatory assertions, the need for in-place initialization of large structs to manage stack growth, and various formatting issues such as line length limits and the requirement for braces in multi-line conditional statements.

Comment thread src/clock/EventClock.zig
/// Initialise in-place. Takes `*EventClock` (not returning by value)
/// because `TimeSource.fromIo` stores a pointer into `self.io`, making
/// the struct self-referential.
pub fn init(self: *EventClock, allocator: Allocator, config: Config, io_handle: std.Io) Error!void {
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

This function, and many others in this module, are missing assertions for arguments and invariants. The repository style guide requires that all function arguments be asserted and that the assertion density averages at least two assertions per function. This is critical for the safety-first design goals of the project.

References
  1. Assert all function arguments and return values, pre/postconditions and invariants. The assertion density of the code must average a minimum of two assertions per function. (link)

Comment thread src/clock/EventClock.zig Outdated
.clock = undefined,
.waiters = WaiterQueue.initContext({}),
};
self.clock = SlotClock.init(config, TimeSource.fromIo(&self.io)) 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

Following the style guide, SlotClock should be initialized in-place using an out pointer since it exceeds 16 bytes. This avoids unnecessary copies and stack growth.

    try self.clock.init(config, TimeSource.fromIo(&self.io));
References
  1. Construct larger structs in-place by passing an out pointer during initialization. If the argument type is more than 16 bytes, pass the argument as *const. (link)

Comment thread src/clock/EventClock.zig
Comment on lines +142 to +143
if (self.next_listener_id == std.math.maxInt(ListenerId))
return error.ListenerLimitReached;
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 if statement should either be on a single line or use braces to adhere to the style guide and prevent potential bugs.

    if (self.next_listener_id == std.math.maxInt(ListenerId)) return error.ListenerLimitReached;
References
  1. Add braces to the if statement unless it fits on a single line. (link)

Comment thread src/clock/EventClock.zig
self.allocator.destroy(state);
return WaitForSlotResult.immediate(error.Aborted);
}
self.waiters.push(self.allocator, .{ .target = target, .state = state }) catch return error.OutOfMemory;
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

This line exceeds the 100-column limit specified in the style guide.

    self.waiters.push(self.allocator, .{
        .target = target,
        .state = state,
    }) catch return error.OutOfMemory;
References
  1. Hard limit all line lengths, without exception, to at most 100 columns. (link)

Comment thread src/clock/SlotClock.zig
Comment on lines +71 to +78
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;
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

Structs larger than 16 bytes should be initialized in-place using an out pointer to avoid stack growth and unnecessary copies.

pub fn init(self: *SlotClock, config: Config, time: TimeSource) error{InvalidConfig}!void {
    try config.validate();
    self.* = .{
        .config = config,
        .time = time,
        .current_slot = slot_math.slotAtMs(config, time.nowMs()),
    };
}
References
  1. Construct larger structs in-place by passing an out pointer during initialization. (link)

Comment thread src/clock/SlotClock.zig
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;
return if (next_slot_ms -| now_ms < self.config.maximum_gossip_clock_disparity_ms) next_slot else current;
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

This line exceeds the 100-column limit. Additionally, the style guide prefers explicit branches over complex ternary-like expressions for clarity.

    if (next_slot_ms -| now_ms < self.config.maximum_gossip_clock_disparity_ms) {
        return next_slot;
    }
    return current;
References
  1. Hard limit all line lengths to 100 columns. Add braces to the if statement unless it fits on a single line. (link)

Comment thread src/clock/slot_math.zig
Comment on lines +25 to +26
if (self.seconds_per_slot == 0 or self.slots_per_epoch == 0)
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

Compound conditions should be split into simple conditions using nested branches or separate statements. Also, the if statement requires braces if it spans multiple lines.

        if (self.seconds_per_slot == 0) return error.InvalidConfig;
        if (self.slots_per_epoch == 0) return error.InvalidConfig;
References
  1. Split compound conditions into simple conditions using nested if/else branches. Add braces to the if statement unless it fits on a single line. (link)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new src/clock/ module providing slot/epoch timing utilities and an event-driven beacon clock, integrating it into the build as a standalone module with dedicated tests.

Changes:

  • Introduces Layer 0 pure arithmetic helpers (slot_math) and associated config validation.
  • Adds Layer 1 SlotClock with pluggable TimeSource plus unit tests.
  • Adds Layer 2 EventClock implementing an async auto-advance loop, listener registration, and waitForSlot, and wires clock tests into build.zig.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/clock/slot_math.zig Pure slot/epoch arithmetic + config validation + tests.
src/clock/time_source.zig Pluggable “now in ms” abstraction with std.Io-backed implementation.
src/clock/SlotClock.zig Stateful slot clock built on slot_math, with gossip-disparity/tolerance helpers + tests.
src/clock/EventClock.zig Async event loop with slot/epoch listeners and slot waiters + tests.
src/clock/root.zig Public module root re-exporting clock layers/types.
build.zig Registers clock module and adds test:clock build step.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/clock/EventClock.zig Outdated
Comment thread src/clock/EventClock.zig
Comment on lines +111 to +114
var maybe_future = self.loop_future;
self.loop_future = null;
if (maybe_future) |*future| {
future.cancel(self.io);
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

join() claims it “wait[s] for [the loop] to finish”, but it only calls future.cancel() and never awaits the future. Additionally, if a caller invokes join() without calling stop() first, runAutoLoop() currently treats sleep cancellation as a transient error and continues looping, so the canceled fiber can keep running after loop_future is dropped. Consider making join() set stopped = true (or call stop()), and then await the future to guarantee the loop has actually exited before returning.

Suggested change
var maybe_future = self.loop_future;
self.loop_future = null;
if (maybe_future) |*future| {
future.cancel(self.io);
// Ensure the loop has been signaled to stop.
self.stop();
var maybe_future = self.loop_future;
if (maybe_future) |*future| {
// Clear the stored handle before waiting, so a subsequent `start`
// can safely create a new loop.
self.loop_future = null;
// Request cancellation and wait for the fiber to exit.
future.cancel(self.io);
_ = future.*.wait(self.io);

Copilot uses AI. Check for mistakes.
Comment thread src/clock/EventClock.zig Outdated
Comment thread src/clock/EventClock.zig
Comment on lines +277 to +281
/// Create an immediately-resolved result (no async work needed).
/// Relies on `std.Io.Future.await` returning `.result` when `.any_future == null`.
fn immediate(result: Error!void) WaitForSlotResult {
return .{ .inner = .{ .any_future = null, .result = result }, .state = null, .clock = null };
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

WaitForSlotResult.immediate constructs a std.Io.Future by manually setting internal fields (any_future = null, result = ...) and relies on a specific Future.await behavior when any_future == null. This couples the code to std.Io.Future representation/semantics and is brittle across Zig stdlib changes. Prefer using a documented helper/constructor for an already-resolved future, or adjust the API to avoid fabricating a Future when no async work is needed.

Copilot uses AI. Check for mistakes.
Comment thread src/clock/EventClock.zig
Comment on lines +283 to +291
pub fn await(self: *WaitForSlotResult, io: std.Io) Error!void {
const result = self.inner.await(io);
// Free AFTER await returns — workaround for Zig futex use-after-free
// where GCD still holds a reference to the event address after wake.
if (self.state) |s| s.allocator.destroy(s);
self.state = null;
self.clock = null;
return result;
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

WaitForSlotResult.await requires the caller to pass a std.Io even though the result already captures the io used to create the future (via EventClock.io / WaitState.io). Passing a different io than the one used for std.Io.async is likely incorrect and can cause hard-to-debug runtime issues. Consider storing the std.Io inside WaitForSlotResult and using it internally (or at least documenting/enforcing that the same io must be passed).

Copilot uses AI. Check for mistakes.
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.

currently it seems this is the only way to do it

Comment thread build.zig Outdated
@GrapeBaBa GrapeBaBa marked this pull request as draft April 1, 2026 04:14
@GrapeBaBa
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

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

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: 0d31d281e8

ℹ️ 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
Comment on lines +112 to +114
self.loop_future = null;
if (maybe_future) |*future| {
future.cancel(self.io);
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 Stop loop before dropping future handle in join

join() clears loop_future and only calls future.cancel(self.io), but runAutoLoop explicitly treats canceled sleeps as recoverable (sleep(... ) catch { continue; }) unless stopped is set. In the start() -> join() path (without stop() first), the loop can keep running while loop_future is already null, so a later start() can spawn a second loop and duplicate slot/epoch dispatches. This is a correctness issue for any caller that follows the current join docstring and expects it to fully terminate the loop.

Useful? React with 👍 / 👎.

- TimeSource is now a tagged union (io: std.Io / fake: *FakeTime),
  eliminating the self-referential struct problem in EventClock.init
- Add FakeTime struct with setMs/advanceMs/advanceSlot methods
- Fix join(): call stop() and await future after cancel
- Fix runAutoLoop: break on error.Canceled instead of continuing
- Fix ArrayListUnmanaged init syntax for Zig 0.16 (.empty)
TimeSource variants are now both proper structs:
- RealClock: wraps std.Io, encapsulates wall-clock reading
- FakeTime: mutable test clock with set/advance methods
The clock module uses only std.Io APIs with no direct libc dependency.
Comment thread src/clock/slot_math.zig

pub const Config = struct {
genesis_time_sec: UnixSec,
seconds_per_slot: u64,
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.

Preferred ChainConfig value to use is slot_duration_ms (seconds_per_slot is deprecated and unused in lodestar).

Also just worth pointing out now, we are anticipating (maybe in h-fork) that we will move to 6 second slots. So might be worth thinking about how you would handle that (eg slotAtMs and slotAtSec would need to consume ChainConfig at a minimum)
See lodekeeper/lodestar@44a4048

GrapeBaBa added a commit that referenced this pull request May 8, 2026
Addresses Copilot reviews on PR #301:

1. `immediate` no longer pokes `std.Io.Future` field layout — the union's
   `.immediate` variant carries the result directly, no more
   `.{ .any_future = null, .result = ... }` struct literal that depended
   on stdlib internal field names.

2. `await` drops the `io: std.Io` parameter — the pending variant stores
   `state.io` (the io that created the future), so we no longer rely on
   the caller passing the same io and can't get io-mismatch bugs.

State/clock fields move from `?*WaitState`/`?*EventClock` (sentinel-null)
into the `.pending` payload as non-optional pointers; `.immediate` carries
no extra state. After `await` / `cancel` we transition self to
`.immediate = result` so subsequent calls are idempotent no-ops.

9 test sites updated: `fut.await(io_handle)` → `fut.await()`.

`zig fmt --check` passes. (Pre-existing `b.modules.put` build error in
build.zig is unrelated.)
@GrapeBaBa
Copy link
Copy Markdown
Contributor Author

Closing in favor of #354 — replanted onto current main. PR #301 had drifted 40 commits behind with 33 unrelated Zig 0.16 migration commits that obscured review (see codex feedback citing build.zig / metrics_stub / fuzz extractor / etc., none of which are clock-related). #354 is +1596 lines of pure clock work on top of zbuild.

@GrapeBaBa GrapeBaBa closed this May 8, 2026
GrapeBaBa added a commit that referenced this pull request May 8, 2026
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.
GrapeBaBa added a commit that referenced this pull request May 9, 2026
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`.
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.

3 participants