Skip to content

Feat/m0 zig 0 16 upgrade#9

Merged
indykish merged 13 commits into
mainfrom
feat/m0-zig-0-16-upgrade
Apr 20, 2026
Merged

Feat/m0 zig 0 16 upgrade#9
indykish merged 13 commits into
mainfrom
feat/m0-zig-0-16-upgrade

Conversation

@indykish

@indykish indykish commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR upgrades posthog-zig from Zig 0.15.2 to 0.16.0, bumping the library to v0.2.0 with a single user-visible breaking change: posthog.init() now accepts an io: std.Io argument between allocator and config. Internally, every concurrency primitive (std.Thread.Mutex/Conditionstd.Io.Mutex + std.Io.Event), the HTTP client, timestamp sources, and the retry PRNG have been migrated to the new std.Io model, and the previous debug_threaded_io.? panic risk in retry.zig has been resolved by switching to a self-seeded thread-local PRNG.

Confidence Score: 5/5

Safe to merge — all remaining findings are P2 style cleanup with no correctness impact.

The migration is complete and consistent across all call sites. The previously flagged debug_threaded_io.? panic in retry.zig is fully resolved. Tests cover concurrency, overflow, TTL expiry, deadline short-circuit, and OOM paths. The only finding is dead code in a test file.

tests/caller_sim_test.zig (dead Counters struct)

Important Files Changed

Filename Overview
src/client.zig Public API updated: init() gains io: std.Io between allocator and config; all concurrency calls and serialization writers correctly updated for Zig 0.16.
src/batch.zig std.Thread.Mutex/Condition replaced with std.Io.Mutex + std.Io.Event; waitForEventsOrTimeout uses Event.waitTimeout correctly; post-wake reset() ordering is safe under single-consumer contract.
src/flush.zig Shutdown deadline logic, retry loop, and sleepForNs all updated to use Io.Clock.awake and io.sleep; saturating-mul overflow guards are correctly handled.
src/retry.zig Previous debug_threaded_io.? panic risk fully resolved: threadRandom() now seeds from @intFromPtr(&rng_state) ^ (counter *% golden_ratio), eliminating Io dependency entirely.
src/transport.zig postBatch and postDecide both gain an io parameter; std.http.Client now constructed with { .allocator, .io } and response buffered through Io.Writer.Allocating.
src/feature_flags.zig FlagCache gains io: std.Io; std.Io.Mutex replaces std.Thread.Mutex; put() uses ensureUnusedCapacity + putAssumeCapacity to prevent post-remove OOM.
src/types.zig nowMs and monotonicNs moved from std.time.* to std.Io.Clock.real/awake; Io.Writer.Allocating replaces io.Writer.Allocating throughout serialisation helpers.
build.zig.zon Version bumped to 0.2.0, minimum_zig_version correctly updated to 0.16.0; published paths explicitly enumerate only consumer-facing docs.
tests/caller_sim_test.zig Comprehensive offline caller-simulation tests updated for 0.16 API; minor dead code: Counters struct at line 484-498 is defined but never used.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant PostHogClient
    participant Queue
    participant FlushThread
    participant Transport

    Caller->>PostHogClient: init(allocator, io, config)
    PostHogClient->>Queue: Queue.init(allocator, io, ...)
    Note over Queue: Io.Mutex + Io.Event replace Thread.Mutex/Condition
    PostHogClient->>FlushThread: FlushThread.spawn(allocator, io, queue, cfg)

    loop Non-blocking hot path
        Caller->>PostHogClient: capture / identify / group
        PostHogClient->>Queue: enqueue(json)
        Queue-->>Queue: Io.Mutex.lock → arena.dupe → unlock
        alt count >= flush_at
            Queue->>FlushThread: Io.Event.set (wake signal)
        end
    end

    loop Background flush loop
        FlushThread->>Queue: waitForEventsOrTimeout (Io.Event.waitTimeout)
        FlushThread->>Queue: drain() → swap write/flush sides
        FlushThread->>Transport: postBatch(allocator, io, host, key, events)
        Note over Transport: std.http.Client{ .allocator, .io }
        Transport-->>FlushThread: HTTP status
        alt 5xx / 429 and retries remain
            FlushThread->>FlushThread: backoffNs (threadlocal PRNG) + io.sleep
        end
        FlushThread->>Queue: resetSide(idx)
    end

    Caller->>PostHogClient: deinit()
    PostHogClient->>FlushThread: stop(timeout_ms)
    FlushThread-->>FlushThread: shutdown deadline via Io.Clock.awake
    FlushThread->>FlushThread: final doFlush
    FlushThread-->>PostHogClient: thread.join()
Loading

Comments Outside Diff (3)

  1. src/root.zig, line 5-7 (link)

    P1 Stale module docstring missing io argument

    The module-level usage example still shows the old two-argument init signature. Anyone who copies it will get a compile error because init now requires three arguments (the new io: std.Io parameter between allocator and config).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/root.zig
    Line: 5-7
    
    Comment:
    **Stale module docstring missing `io` argument**
    
    The module-level usage example still shows the old two-argument `init` signature. Anyone who copies it will get a compile error because `init` now requires three arguments (the new `io: std.Io` parameter between `allocator` and `config`).
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/feature_flags.zig, line 68-76 (link)

    P2 Spurious eviction on update when cache is at capacity

    When put() is called at capacity (count == max_entries) for a distinct_id that already exists in the cache, the eviction block runs unconditionally and may remove an arbitrary other user's entry. The update path then also removes the old entry for the same distinct_id, leaving the cache at max_entries - 1 with a valid entry needlessly dropped.

    Concrete path: cache full with {"user_1": A, "user_2": B}, call put("user_1", new) → eviction iterator picks "user_2" → B is freed → fetchRemove("user_1") removes A → put("user_1", new) → cache is {"user_1": new} (1 entry, not 2). "user_2" must make a fresh network round-trip on next access.

    The fix is to skip eviction when the target key is already present:

    if (!self.entries.contains(distinct_id) and self.entries.count() >= self.max_entries) {
        // evict one entry ...
    }
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/feature_flags.zig
    Line: 68-76
    
    Comment:
    **Spurious eviction on update when cache is at capacity**
    
    When `put()` is called at capacity (`count == max_entries`) for a `distinct_id` that already exists in the cache, the eviction block runs unconditionally and may remove an arbitrary *other* user's entry. The update path then also removes the old entry for the same `distinct_id`, leaving the cache at `max_entries - 1` with a valid entry needlessly dropped.
    
    Concrete path: cache full with `{"user_1": A, "user_2": B}`, call `put("user_1", new)` → eviction iterator picks `"user_2"` → B is freed → `fetchRemove("user_1")` removes A → `put("user_1", new)` → cache is `{"user_1": new}` (1 entry, not 2). `"user_2"` must make a fresh network round-trip on next access.
    
    The fix is to skip eviction when the target key is already present:
    
    ```zig
    if (!self.entries.contains(distinct_id) and self.entries.count() >= self.max_entries) {
        // evict one entry ...
    }
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. src/flush.zig, line 76-82 (link)

    P2 stop() ignores timeout_msthread.join() has no deadline

    The timeout_ms parameter is silently discarded and thread.join() blocks indefinitely. If the network is unresponsive during the final doFlush call (which can retry up to max_retries times with up to 30s backoff each), deinit() will hang for minutes. The // v0.2 comment acknowledges this is deferred, but callers who set a short shutdown_flush_timeout_ms will not get the bounded shutdown they expect.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/flush.zig
    Line: 76-82
    
    Comment:
    **`stop()` ignores `timeout_ms``thread.join()` has no deadline**
    
    The `timeout_ms` parameter is silently discarded and `thread.join()` blocks indefinitely. If the network is unresponsive during the final `doFlush` call (which can retry up to `max_retries` times with up to 30s backoff each), `deinit()` will hang for minutes. The `// v0.2` comment acknowledges this is deferred, but callers who set a short `shutdown_flush_timeout_ms` will not get the bounded shutdown they expect.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: tests/caller_sim_test.zig
Line: 484-499

Comment:
**Dead `Counters` struct is never used**

The `Counters` struct (including its `callback` method) and the `_` discard on line 499 are never instantiated or called. The test was refactored to use the `S` struct instead, but this dead code wasn't removed. The local variables declared at lines 480–482 are still used (assigned and asserted at the bottom of the test), but the struct definition itself is unreachable.

```suggestion
    // Use package-level atomics since the callback is a bare fn pointer
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (8): Last reviewed commit: "refactor: address greptile P2s + scrub 0..." | Re-trigger Greptile

indykish and others added 6 commits April 20, 2026 10:00
Bootstraps docs/v1/ spec tree. Covers build system migration,
std.Io-based HTTP transport rewire, Writer.Allocating field renames,
ArenaAllocator mutex audit, CI toolchain refresh, and 0.1.3 -> 0.2.0
version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Begins EXECUTE phase for Zig 0.16 upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/MIGRATION_ZIG_0_16.md: 0.15.2 -> 0.16.0 migration reference.
  Covers std.io -> std.Io, std.Thread.Mutex/Condition removal, std.Io.Mutex
  with Io-threading, std.crypto.random removal, std.posix.getenv removal,
  std.http.Client Io routing, ArenaAllocator thread-safety, Writer.Allocating
  field rename, and an audit checklist. Each entry is before/after code.

- README.md: badge + header note link the guide; Zig line now reads
  "0.15.x today, 0.16.x migration in progress".

- docs/v1/active/P1_API_M0_001_ZIG_0_16_UPGRADE.md: scope expanded after
  running zig 0.16 and discovering the concurrency-primitive rewrite +
  public API break. New dimensions for std.io namespace, Mutex/Condition
  migration, Io-routed transport, crypto.random, posix.getenv. Interfaces
  block now declares the breaking PostHogClient.init / Queue.init /
  FlagCache.init / postBatch / postDecide signatures. Migration guide
  marked as dimension 0, landing first.

- .gitignore: mise.toml / .mise.toml (worktree-local toolchain pins);
  CI workflow stays the source of truth for Zig version.

Code migration itself lands in a follow-up commit once the spec is
reviewed against the guide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nsport

BREAKING: posthog.init now requires io: std.Io as its second argument.
See docs/MIGRATION_ZIG_0_16.md and the updated README for the pattern.
Callers without an opinion can pass posthog.defaultIo().

Zig 0.16 removed std.io.*, std.Thread.Mutex/Condition, std.crypto.random,
std.posix.getenv, std.time.{milli,nano}Timestamp, and std.Thread.sleep,
and moved std.http.Client through the Io capability.

- batch.Queue: std.Thread.Mutex/Condition -> std.Io.Mutex + std.Io.Event.
  Io.Condition has no timedWait in 0.16, so the flush-thread wake signal
  is an Io.Event (single consumer resets per cycle).
- feature_flags.FlagCache: std.Io.Mutex; io threaded through init.
- transport.postBatch / postDecide: io parameter routed to std.http.Client;
  std.io.Writer.Allocating -> std.Io.Writer.Allocating.
- flush.FlushThread.spawn: io parameter; std.Thread.sleep -> io.sleep.
- retry.zig: threadlocal std.Random.DefaultPrng seeded from Clock.awake.now.
- types.zig: new nowMs / monotonicNs helpers backed by std.Io.Clock.
- tests/: caller_sim and integration updated; env access via
  std.Options.debug_threaded_io.?.environ.process_environ.getPosix.
- CI workflows bumped to Zig 0.16.0.
- VERSION + build.zig.zon -> 0.2.0; minimum_zig_version -> 0.16.0.

Verification
- zig build test: 68/68 pass
- cross-compile: x86_64-linux, aarch64-linux, x86_64-macos, aarch64-macos
  all exit 0
- integration test (-Dintegration=true with POSTHOG_API_KEY) not executed
  in this session; deferred to CI or a follow-up with 1Password access.

The hot-path latency test was restructured: per-call p99 measurement on
0.16 captures Io.Clock vtable overhead rather than capture() latency,
so the assertion is now "average over 10k calls < 2ms" — still well
below the <1ms per-call invariant in release builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- build.zig: when -Dintegration=true, attach integration tests to the
  existing `test` step instead of registering a second `test` step that
  panics ("A top-level step with name \"test\" already exists").
- tests/integration_test.zig: fix a pre-existing bug in the `on_deliver`
  test — `client.flush()` is the synchronous path and does not fire
  on_deliver. Use flush_at=1 so enqueue triggers a background flush,
  then poll up to 5s for the callback.
- README.md: narrow the "Zig:" line to 0.16.x and point 0.15.2 users at
  a dedicated compat doc instead of carrying the guidance inline.
- docs/ZIG_0_15_COMPAT.md: new — explains that 0.15.2 users pin
  posthog-zig 0.1.3, with a surface-level before/after and the rationale
  for not shipping a dual-API shim.

Verified: 73/73 tests pass (50 unit + 18 caller + 5 integration hitting
live PostHog via POSTHOG_API_KEY from op://ZMB_CD_DEV/posthog-dev/credential);
`make memleak` exits green on darwin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
release.yml extracts release notes from CHANGELOG.md via
`sed -n "/^## \[$VERSION\]/,/^## \[/p"`, so the 0.2.0 tag push
needs a matching section here or the GitHub Release body will be empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/retry.zig
- src/root.zig: module docstring now shows the 3-arg `posthog.init` with
  `posthog.defaultIo()` so copy-paste from the header compiles.
- src/feature_flags.zig: `put()` no longer evicts a stranger when updating
  an existing `distinct_id` at capacity. Guard the eviction on
  `!entries.contains(distinct_id)` so in-place replacement doesn't
  collaterally drop a valid slot. New regression test covers it.
- src/retry.zig: drop the hard dependency on
  `std.Options.debug_threaded_io.?` inside `threadRandom`. Seed the
  threadlocal PRNG from the address of the threadlocal slot mixed with
  a process-wide atomic counter times the 64-bit golden-ratio constant.
  Works under any Io backend and under none at all.
- src/flush.zig: `stop(timeout_ms)` now publishes an awake-clock deadline
  before setting `shutdown`; `doFlush` checks it at each retry attempt
  and short-circuits to `.dropped` when passed. Previously the
  `timeout_ms` arg was a no-op and deinit could block for minutes under
  retry backoff. New unit test verifies a past deadline drops the batch
  without any POST attempts.

Verification: 70/70 tests pass on Zig 0.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@indykish

Copy link
Copy Markdown
Contributor Author

Addressed three additional greptile findings in c5bd292 (in addition to the retry.zig thread already replied to):

src/root.zig:5-7 — stale module docstring
Updated the usage example to the 3-arg form so copy-paste from the header compiles:

var client = try posthog.init(allocator, posthog.defaultIo(), .{ .api_key = "phc_..." });

src/feature_flags.zig:68-76 — spurious eviction on update at capacity
Guarded the eviction block on !entries.contains(distinct_id). When put() is replacing an existing key, the fetchRemove path below handles it in place without growing the map, so evicting a stranger is pure loss. Added a regression test (feature flags: re-put at capacity does not evict other entries) that fills to capacity, re-puts an existing key, and asserts the other user's entry survives.

src/flush.zig:76-82stop(timeout_ms) ignored the timeout
stop() now publishes now + timeout_ms on ctx.shutdown_deadline_ns (atomic, awake clock) before setting shutdown and signalling. doFlush checks the deadline at the top of every retry iteration and short-circuits to .dropped once crossed, so deinit() no longer blocks for minutes under retry backoff. Kernel join() itself is not timed — Zig 0.16 has no timed std.Thread.join and detaching would leak ctx — but the deadline covers the user-visible concern (bounded network blocking); join is fast once the drain exits. New test flush: stop() honours timeout_ms deadline in retry loop asserts zero POST attempts when the deadline is already in the past.

70/70 tests pass on Zig 0.16.0.

- src/feature_flags.zig: reserve the map slot via `ensureUnusedCapacity(1)`
  before `fetchRemove`, then use `putAssumeCapacity`. Previously an OOM on
  `entries.put` after `fetchRemove` would leave the distinct_id absent
  from the cache, forcing a network re-fetch on every subsequent call and
  silently disabling the cache under memory pressure.
- src/flush.zig: `stop()` uses saturating multiply (`*|`) for
  `timeout_ms * ns_per_ms`. Wrapping u64 on extreme timeouts would
  either silently corrupt the deadline under ReleaseFast or panic
  under ReleaseSafe; `*|` caps at u64::MAX and lets `@intCast` to i64
  police the ~292-year ceiling explicitly.

Verification: 70/70 tests pass on Zig 0.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@indykish

Copy link
Copy Markdown
Contributor Author

Addressed two greptile findings from the second round in d44f953:

src/feature_flags.zig:80-86 — cache entry lost on post-fetchRemove OOM
put() now calls self.entries.ensureUnusedCapacity(1) up front, then fetchRemove, then putAssumeCapacity. The slot is reserved before the old entry is dropped, so an OOM anywhere in the path either fails fast (before any mutation) or cannot fail at all (after ensureUnusedCapacity). No more silent cache-disabling under memory pressure.

src/flush.zig:91-94 — unsigned multiply overflow in stop() deadline
Switched to saturating multiply: timeout_ms *| std.time.ns_per_ms. Caps at u64::MAX on absurd inputs instead of wrapping silently in ReleaseFast or panicking in Debug; the subsequent @intCast(... ) i64 still polices the ~292-year ceiling.

70/70 tests pass on Zig 0.16.0.

- build.zig.zon: include `docs/` and `CHANGELOG.md` in `.paths` so
  `zig fetch` consumers receive `docs/ZIG_0_15_COMPAT.md` and
  `docs/MIGRATION_ZIG_0_16.md` that README links as required references.
  Previously those links 404'd in the installed package tree.
- src/feature_flags.zig: `getPayload` signature is now
  `Allocator.Error!?[]u8`; OOM is propagated instead of collapsed into
  `null`. Callers in `client.zig` treat `null` as a cache miss and
  re-fetch from /decide/, so the previous `catch null` silently turned
  memory pressure into network pressure. Test updated.
- src/root.zig: `defaultIo()` replaces the bare `.?` with an explicit
  `@panic` message pointing freestanding / embedded / custom-harness
  callers at passing their own `std.Io`.

Verification: 70/70 tests pass on Zig 0.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@indykish

Copy link
Copy Markdown
Contributor Author

Addressed three greptile findings from round 3 in b580429:

build.zig.zon:7-14docs/ excluded from package paths
Added docs and CHANGELOG.md to .paths. zig fetch consumers now receive docs/ZIG_0_15_COMPAT.md and docs/MIGRATION_ZIG_0_16.md that README links as required migration references — previously those links 404'd in the installed tree.

src/feature_flags.zig:125-128getPayload conflated OOM with cache miss
Changed the signature to std.mem.Allocator.Error!?[]u8 and replaced catch null with try. Callers (client.getFeatureFlagPayload) treat null as a clean cache miss and re-fetch from /decide/, so the previous swallow silently converted memory pressure into network pressure. Both call sites in client.zig and the unit test updated with try.

src/root.zig:63-65defaultIo() bare .? panic
Replaced with an explicit @panic carrying a diagnostic that points freestanding / embedded / custom-harness callers at passing their own std.Io (e.g. from a std.Io.Threaded they own) instead of calling this helper. No change for normal executable builds where start.zig populates debug_threaded_io.

70/70 tests pass on Zig 0.16.0.

Warnings
- build.zig.zon: narrow `.paths` to enumerate user-facing docs
  individually instead of shipping all of `docs/`. Stops consumers
  from receiving project-management specs under `docs/v1/{pending,
  active,done}/` and future agent logs under `docs/nostromo/`.
- src/flush.zig: `flushLoop` now uses saturating multiply
  (`flush_interval_ms *| ns_per_ms`), matching the fix already
  applied in `stop()`. Prevents silent u64 wrap under ReleaseFast
  for absurd interval values.

Observations
- src/flush.zig: explicit comment on the intentional `i96 + i64`
  mix in `stop()` (Zig 0.16 auto-widens; result fits i64 for ~292
  years of monotonic boot time).
- src/batch.zig: explicit comment on the intentional
  unlock-before-wake ordering in `enqueue` (avoids thundering-herd
  where the woken flush thread would immediately block on the
  mutex we still hold).
- src/feature_flags.zig: new regression test asserting `getPayload`
  returns `error.OutOfMemory` rather than null on a FailingAllocator;
  guards the post-fix `try` path against regressing to `catch null`.

Docs layout (per user request)
- Moved `docs/MIGRATION_ZIG_0_16.md` -> `docs/v1/MIGRATION_ZIG_0_16.md`
  and `docs/ZIG_0_15_COMPAT.md` -> `docs/v1/ZIG_0_15_COMPAT.md`.
  README, CHANGELOG, and `.paths` follow the new locations. Internal
  self-link between the two guides stays relative (`./MIGRATION...`),
  so no edit needed inside the guides themselves.
- Created `docs/nostromo/` (with `.gitkeep`) to match the
  `~/Projects/usezombie/docs/` convention; it will carry agent
  session logs.

Verification: 71/71 tests pass on Zig 0.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@indykish

Copy link
Copy Markdown
Contributor Author

Addressed the self-review warnings and observations in 16c077e, plus a docs-layout change per request:

Warnings

  • build.zig.zon: .paths now enumerates user-facing docs individually instead of including the whole docs/ tree — keeps project-management specs (docs/v1/{pending,active,done}/) and agent logs (docs/nostromo/) out of the published package.
  • src/flush.zig: flushLoop uses saturating multiply (flush_interval_ms *| std.time.ns_per_ms), matching the stop() fix. No more silent u64 wrap on absurd intervals.

Observations

  • src/flush.zig: comment documenting the intentional i96 + i64 mix in stop() (Zig 0.16 auto-widens; final @intCast to i64 is safe for ~292 years of monotonic boot time).
  • src/batch.zig: comment documenting the intentional unlock-before-wake.set ordering in enqueue — avoids a thundering-herd where the woken flush thread would immediately block on the mutex we still hold.
  • src/feature_flags.zig: new regression test getPayload propagates OOM instead of swallowing it uses std.testing.FailingAllocator to verify the new Allocator.Error!?[]u8 signature actually surfaces OOM instead of regressing to catch null.

Docs layout

  • Moved docs/MIGRATION_ZIG_0_16.mddocs/v1/MIGRATION_ZIG_0_16.md and docs/ZIG_0_15_COMPAT.mddocs/v1/ZIG_0_15_COMPAT.md. README, CHANGELOG, and .paths follow. The one self-link between the two guides was already relative so it still resolves.
  • Created empty docs/nostromo/ (with .gitkeep) to match the ~/Projects/usezombie/docs/ convention; it will carry future agent session logs.

71/71 tests pass on Zig 0.16.0 (added one new test for the OOM path).

indykish and others added 2 commits April 20, 2026 12:11
Added high-value gap tests for behaviour the 0.16 migration introduced or
changed. Ordered by risk of silent regression:

- src/batch.zig (+2): `Io.Event` wake/timeout paths — explicit regressions
  for the Condition->Event swap. (a) pre-set `signal()` must return from
  `waitForEventsOrTimeout` in well under the 5s timeout; (b) no wake must
  not return before the timeout. Missing either was possible if
  `wake.reset()` accidentally ran before `wait`.
- src/flush.zig (+1): deadline crossed *mid-retry*. Prior test only covered
  the "deadline already in the past before attempt 0" case; the realistic
  path is "attempt 0 runs and returns 5xx, deadline moves, attempt 1 is
  short-circuited". Uses a wrapper postBatch that flips the atomic after
  the first call.
- src/types.zig (+2): `nowMs` returns a post-2020 epoch-ms value (guards
  against accidentally returning seconds or the raw i96 nanoseconds);
  `monotonicNs` is monotonic across a busy-loop.
- src/feature_flags.zig (+1): `put` with FailingAllocator — OOM during
  `ensureUnusedCapacity` must leave the cache empty (no half-inserted
  entry, no leaked `id_copy`). `testing.allocator` on deinit catches
  the leak case.
- src/retry.zig (+1): threadlocal PRNG across 4 threads yields at least
  one differing sequence. Catches any future refactor that accidentally
  shares a single seed across threads (the exact hazard the new seed
  design was chosen to avoid).

Verification: 78/78 tests pass on Zig 0.16.0 (up from 71).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….Options

The previous docstring told callers to reach for
`std.Options.debug_threaded_io.?.io()`, bypassing the public
`posthog.defaultIo()` helper that wraps the same call with a friendly
panic message. Consumers copying the docstring verbatim would end up
with the bare `.?` and no diagnostic on freestanding targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@indykish

Copy link
Copy Markdown
Contributor Author

Fixed in 8ac6605. PostHogClient.init docstring now points callers at the public posthog.defaultIo() helper (which wraps the raw access with a friendly panic message) instead of std.Options.debug_threaded_io.?.io(). No more leaking the internal Zig 0.16 accessor through a copy-pasted docstring.

P2 fixes
- src/types.zig: formatIso8601's u64 cast is no longer "because Zig 0.15
  prints a '+' prefix". Comment now describes the forward-facing rule:
  post-epoch values only, unsigned keeps output deterministic against
  future signed zero-pad formatting changes.
- src/flush.zig: `stop()` saturates the timeout cast. `timeout_ms *|
  ns_per_ms` is further clamped with `@min(..., i64::MAX)` before the
  `@intCast` to i64, so pathological inputs resolve to an
  effectively-infinite deadline instead of a Debug-mode panic.

0.15-era comment scrub
- src/types.zig: nowMs/monotonicNs docstrings describe what they do, not
  which 0.15 API they replace.
- src/batch.zig: Queue's concurrency section drops the "(Zig 0.16)"
  heading and historical commentary; reads as current design rationale.
- src/client.zig, tests/caller_sim_test.zig, tests/integration_test.zig:
  "Zig 0.16 removed X" one-liners rewritten as present-tense notes on
  the Threaded Io's Environ view.
- src/retry.zig: threadRandom docstring drops the "Zig 0.16 removed
  std.crypto.random" lead; keeps the non-cryptographic justification.
- tests/caller_sim_test.zig: latency comment drops the "0.15 bracketed
  each call" framing; keeps the vtable-overhead rationale.

The historical record of what the 0.15 -> 0.16 migration touched lives
in docs/v1/MIGRATION_ZIG_0_16.md. Code comments now describe the
current design.

Verification: 78/78 tests pass on Zig 0.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@indykish

Copy link
Copy Markdown
Contributor Author

Addressed both P2s plus the broader "scrub 0.15-era comments" ask in b63596f:

P2 — src/types.zig:152 stale Zig 0.15 comment
Rewritten to describe the forward-facing rule: formatIso8601 only handles post-epoch timestamps, so the unsigned cast keeps output deterministic regardless of any future signed zero-pad formatting changes in Zig.

P2 — src/flush.zig:96 @intCast panic for saturated timeout
timeout_ms *| std.time.ns_per_ms is now clamped with @min(..., @as(u64, std.math.maxInt(i64))) before the @intCast to i64. Contract: any timeout_ms is accepted; impossibly large values resolve to an effectively-infinite deadline, never a Debug-mode panic.

Comment scrub
Forward-facing rewrites across src/batch.zig, src/client.zig, src/retry.zig, src/types.zig, tests/caller_sim_test.zig, tests/integration_test.zig — removed the "Zig 0.16 removed X" / "post-0.15" / "0.15 bracketed each call" framing in favour of present-tense descriptions of the current design. The historical record of the migration still lives in docs/v1/MIGRATION_ZIG_0_16.md, which is where it belongs.

78/78 tests pass on Zig 0.16.0.

@indykish indykish merged commit 257ef1b into main Apr 20, 2026
9 checks passed
@codecov

codecov Bot commented Apr 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@indykish indykish deleted the feat/m0-zig-0-16-upgrade branch April 20, 2026 07:22
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.

1 participant