Skip to content

fix(leader): reject lease timings that make split-brain deterministic - #385

Open
dcccrypto wants to merge 1 commit into
mainfrom
fix/keeper-377-leader-lease-timing-validation
Open

fix(leader): reject lease timings that make split-brain deterministic#385
dcccrypto wants to merge 1 commit into
mainfrom
fix/keeper-377-leader-lease-timing-validation

Conversation

@dcccrypto

@dcccrypto dcccrypto commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Closes #377[SECURITY][HIGH] Invalid HA renewal timing creates a deterministic split-brain leader window.

The bug

LeaderLock accepted any timing values. When renewMs >= ttlMs, the Redis lease expires before the leader first attempts renewal. A standby then acquires the freed key and promotes itself, while the original node still reports role() === "leader" — it does not find out otherwise until its renewal finally runs and the fencing script rejects it.

keeperSend gates on-chain writes on that local role alone (setLeaderCheck in index.ts:128), so during that interval both processes submit transactions. That defeats the single-writer guarantee the lease exists to provide, and it is deterministic rather than a race.

Secondary path: index.ts:115-117 builds these via Number(process.env.X ?? default), so any malformed value arrives as NaN. NaN fails every comparison silently — setTimeout(fn, NaN) fires on the next tick (renewal becomes a hot loop) and ex: NaN yields a lease Redis will not honour.

The fix

Validate in the constructor, throwing LeaderLockTimingError when:

  • any of ttlMs / renewMs / pollMs is non-finite or <= 0
  • renewMs >= ttlMs

Failing to boot is the right outcome: a lock built on unsafe timings cannot provide the guarantee its callers assume, and the alternative is a silently split-brained keeper double-submitting liquidations. Errors name the offending field and the env var to fix.

A renew margin thinner than half the TTL is legal but leaves little room for a slow Redis round-trip or an event-loop stall, so that warns rather than throws — a judgement call, not an invariant.

.env.example documents the constraint next to the three variables.

⚠️ Deployment note

This converts a silent misconfiguration into a boot failure. If any environment currently runs HA_ENABLED=true with renewMs >= ttlMs, that keeper will now refuse to start instead of running split-brained. That is the intended trade — a crash is strictly better than two nodes writing on-chain — but check the deployed env before rolling this out so the failure isn't a surprise. The defaults (30s / 10s / 5s) are unaffected; only an explicit override can trip it.

Testing

tests/lib/leader-timing-validation.test.ts — 11 tests. The first is the issue's own PoC, inverted: it drives the exact {ttlMs: 1000, renewMs: 5000, pollMs: 100} that produced two concurrent leaders and asserts the lock now refuses construction, so the unsafe state is unreachable. Others cover the renewMs === ttlMs boundary, renewMs just below ttlMs, NaN/Infinity/0/negative across all three fields, error messages naming the right field, defaults still working, and a valid config still electing exactly one leader through a renewal cycle.

Verified non-vacuous: with the source change reverted, 7 of the 11 fail. The 4 that still pass are the "must not throw" guards, which is correct.

  • npx vitest run986 passed, 33 skipped, 0 failed (97 files)
  • npx tsc --noEmit0 errors; pnpm build → clean

Correction to earlier reports

I previously flagged "3 pre-existing closeQ / PermissionlessCrankArgs typecheck errors on main" on two occasions. That was wrong — they were an artifact of my local node_modules holding @percolatorct/sdk 3.1.0 while the lockfile pins the git tarball at 59441ff3 (3.0.0). After pnpm install --frozen-lockfile, typecheck and build are both clean, which is also why CI was green throughout. No action needed there; apologies for the noise.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Leader election now rejects invalid lease timing configurations before startup, reducing the risk of split-brain transaction submission.
    • Timing values must be finite and positive, with renewal occurring before the lease expires.
    • A warning is shown when the renewal interval leaves a narrow safety margin.
  • Documentation

    • Added configuration guidance for lease timing values, including recommendations for accommodating network latency and event-loop delays.

LeaderLock accepted any timing values. When renewMs >= ttlMs the Redis
lease expires BEFORE the leader first attempts renewal, so a standby
acquires the freed key and promotes itself while the original node still
reports role() === "leader" — it does not learn otherwise until its
renewal finally runs and the fencing script rejects it.

keeperSend gates on-chain writes on that local role alone (index.ts
setLeaderCheck), so in that interval both processes submit transactions.
This defeats the single-writer guarantee the lease exists to provide, and
it is deterministic rather than a race.

index.ts builds these from Number(process.env.X ?? default), so a
malformed value arrives as NaN. NaN fails every comparison silently:
setTimeout(fn, NaN) fires on the next tick, turning renewal into a hot
loop, and ex: NaN yields a lease Redis will not honour.

Validate in the constructor and throw LeaderLockTimingError on:
  - any of ttlMs / renewMs / pollMs non-finite or <= 0
  - renewMs >= ttlMs

Failing to boot is the only safe outcome here: a lock built on unsafe
timings cannot provide the guarantee its callers assume, and the
alternative is a silently split-brained keeper double-submitting
liquidations. Defaults (30s/10s/5s) are unaffected — only an explicit
override can trip this.

A renew margin thinner than half the TTL is legal but leaves little room
for a slow Redis round-trip or an event-loop stall, so it warns rather
than throws. That is a judgement call, not an invariant.

Also document the constraint in .env.example next to the three vars.

Closes #377

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98fa970b-af8b-430d-8b3c-bf2f181a4f00

📥 Commits

Reviewing files that changed from the base of the PR and between e3d969a and 7418e9a.

📒 Files selected for processing (3)
  • .env.example
  • src/lib/leader.ts
  • tests/lib/leader-timing-validation.test.ts

📝 Walkthrough

Walkthrough

The update documents Redis leader-lock timing invariants, validates timing values during LeaderLock construction, warns about narrow renewal margins, and adds regression tests for invalid, boundary, default, and valid configurations.

Changes

Leader lock timing validation

Layer / File(s) Summary
Timing contract and constructor validation
.env.example, src/lib/leader.ts
Documents required timing relationships, adds LeaderLockTimingError and validation for finite positive values, enforces renewMs < ttlMs, and warns when renewal exceeds half the TTL.
Valid lease election behavior
tests/lib/leader-timing-validation.test.ts
Adds an expiring Redis test double and verifies stable leader/standby behavior with valid timing.
Invalid and boundary timing coverage
tests/lib/leader-timing-validation.test.ts
Tests invalid ordering, malformed numeric values, error field names, defaults, equal and near-boundary timings, and thin renewal margins.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: 0x-squidsol

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main change: rejecting unsafe leader lease timings to prevent split-brain.
Linked Issues check ✅ Passed The PR enforces finite positive timing values and renewMs < ttlMs, matching #377's required startup validation and tests.
Out of Scope Changes check ✅ Passed The changes stay within the issue scope: timing validation, documentation, and regression tests only.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/keeper-377-leader-lease-timing-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dcccrypto

Copy link
Copy Markdown
Owner Author

⚠️ The issue this PR references (#377) no longer exists on GitHub.

Sometime between 2026-07-18 23:20 and 23:59 UTC, a contiguous range of keeper issues (#375, #377, #378, #379, #380, #381) and one PR (#376) stopped resolving — gh issue view returns "Could not resolve to an issue or pull request", which means deleted rather than closed. I did not delete them and do not know who or what did.

This does not invalidate the change. The defect fixed here — deterministic HA split-brain via unvalidated lease timings — was verified directly against the code, not taken on faith from the issue text:

  • the regression tests in this PR fail without the source change and pass with it
  • the full keeper suite passes
  • CI is green

The PR description above is self-contained and restates the vulnerability, its reachability, and the design decisions in full, so it can be reviewed without the original issue.

Consequences to be aware of:

  • the Closes #377 link will not auto-close anything and will 404 for a reviewer
  • if the issue is restored later, that link becomes live again — no action needed here

Raised with PM. Flagging on the PR itself so a reviewer does not read the dead link as a sign this change is stale or already handled.

@dcccrypto

dcccrypto commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@Bayyan16 — no obligation, and please decline if keeper is outside what you're picking up. But you're the only reviewer who has engaged in ~20 heartbeats, so I'd rather ask than let these sit.

I have three security PRs open here. Ranked so you can take only the top one if time is short:

  1. fix(oracle): reject DexScreener pairs where the queried mint is the quote token #382 — DexScreener quote-side price injection. An attacker's inner CPI can set the recorded price of their own trades. No operator error required — triggers on any Jupiter outage.
  2. fix(leader): reject lease timings that make split-brain deterministic #385 — HA lease timings unvalidated → deterministic split-brain, two keepers submitting on-chain. Requires operator misconfiguration to trigger.
  3. fix(deps): override @opentelemetry/core to >=2.8.0 (CVE-2026-54285) #386CVE-2026-54285 (@opentelemetry/core) reachable via Sentry on an unauthenticated HTTP path. Dependency override only, no source change.

This one is #2: serious but needs a misconfiguration to reach.

Validates lease timings at construction (renewMs < ttlMs, all finite and > 0) and throws. Deliberately fail-fast: a lock built on unsafe timings cannot provide its guarantee. 11 tests, 7 fail without the fix. Deployment note in the PR body: this turns a bad HA config into a boot failure by design — worth your eye on whether that trade is right.

All three: keeper main CI green, MERGEABLE, no conflicts. Same caveat as launch #2437 — a code approval is not deploy authorization, and I won't self-merge on review alone. Keeper main is genuinely its active branch (unlike launch), so these don't have the branch-staleness problem those had.

@Bayyan16

Copy link
Copy Markdown
Contributor

Thanks @dcccrypto for the additional context. I’ll take #385 as a separate review.

I’ll independently validate the fail-fast timing invariant, including the exact renewMs >= ttlMs boundary, non-finite and non-positive values, the warning-only margin case, and the reported non-vacuous regression behavior.

I’ll also verify that valid/default configurations continue to elect a single leader and that the constructor rejection does not introduce unrelated behavior changes.

Any review will remain scoped to the exact commit validated. I do not have merge/write access, so merge remains a maintainer decision.

@dcccrypto

Copy link
Copy Markdown
Owner Author

@Bayyan16#385 is the last unreviewed one of the three. Same treatment as #382: here's a verified 2-minute recipe. I ran every step just now, so the commands and expected output are exact, not approximate.

gh pr checkout 385 -R dcccrypto/percolator-keeper
npx vitest run tests/lib/leader-timing-validation.test.ts
# expected:  Tests  11 passed (11)

Anti-vacuity check:

cp src/lib/leader.ts /tmp/l-fix.ts
git show origin/main:src/lib/leader.ts > src/lib/leader.ts
npx vitest run tests/lib/leader-timing-validation.test.ts
# expected:  Tests  7 failed | 4 passed (11)
cp /tmp/l-fix.ts src/lib/leader.ts        # restore

The 4 that still pass are the "must not throw" guards (valid config, defaults, thin-margin-is-legal) — correct that they pass either way.

The bug, briefly: LeaderLock never validated its lease timings. If renewMs >= ttlMs, the Redis lease expires before the leader first attempts renewal — a standby acquires the freed key and promotes itself while the original node still reports role() === "leader", because it doesn't learn otherwise until its renewal runs and the fencing script rejects it. keeperSend gates on-chain writes on that local role alone, so both nodes submit transactions in the gap. Deterministic, not a race. Secondarily, index.ts builds these via Number(process.env.X ?? default), so a malformed value arrives as NaN — which fails every comparison silently and makes setTimeout fire on the next tick.

The judgement call, which is the thing actually worth your eye — more than the code:

It fails fast at construction. A bad HA config becomes a boot failure rather than a silent split-brain. That's a deliberate trade: refusing to start is louder and more disruptive than degrading, and it means a config typo takes the keeper down instead of quietly halving its safety.

I chose it because a lock built on unsafe timings cannot provide the guarantee its callers assume, so running anyway is worse than not running. But there's a real argument for degrade-to-standalone-leader-with-a-warning instead, and if you find that more defensible I'll change it. Defaults (30s/10s/5s) are unaffected — only an explicit override can trip it.

There's also a softer sub-decision: renewMs > ttlMs/2 warns rather than throws, since a thin margin is risky but not incorrect. Happy to make that stricter if you'd rather.

No obligation — you've reviewed two already, which is two more than anyone else.

@Bayyan16 Bayyan16 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.

Approved.

I independently reviewed and validated commit 7418e9a08539cc6c1e841222917034bc4c22a2a7 from a fresh isolated worktree.

Confirmed:

  • ttlMs, renewMs, and pollMs must all be finite and greater than zero;
  • renewMs >= ttlMs is rejected during LeaderLock construction, making the deterministic split-brain configuration unreachable;
  • the exact reported configuration (ttlMs: 1000, renewMs: 5000, pollMs: 100) now fails closed before either node can start;
  • renewMs === ttlMs is rejected, while a value just below ttlMs remains accepted;
  • invalid values identify the offending timing field and direct the operator to the relevant HA timing environment variables;
  • default and valid configurations continue to elect exactly one leader through a renewal cycle;
  • a thin but legal renewal margin warns rather than throws;
  • the warning is emitted when renewMs > ttlMs / 2, while the exact half-TTL boundary does not warn;
  • the .env.example guidance is consistent with the enforced constructor invariant.

I also reviewed the fail-fast design decision. I agree that refusing construction is the correct default behavior. Automatically degrading an explicitly HA-configured instance to standalone would remove the single-writer coordination guarantee and could allow multiple replicas to treat themselves as writable leaders. An explicit operator-controlled fallback could be considered separately, but it is not required for this remediation.

I also agree with warning rather than rejecting renewMs > ttlMs / 2. The strict correctness invariant is renewMs < ttlMs; the half-TTL threshold is an operational safety margin whose suitability depends on Redis latency and event-loop behavior.

The regression coverage is non-vacuous:

With constructor timing validation disabled:
7 failed, 4 passed

With validation restored:
11 passed

The four tests that remain green without the fix are the intentional “must not throw” guards for valid and default configurations.
Local validation:

Targeted timing tests:  11 passed, 0 failed
Warning behavior tests: 2 passed, 0 failed
Full suite:             986 passed, 33 skipped, 0 failed
TypeScript validation:  passed
Build:                  passed
git diff --check:       passed
Working tree:           clean
Local/remote HEAD:      identical

I found no blocking issue with the fail-fast lease-timing remediation.

This approval covers the code and security correctness of the exact validated commit only. I do not have merge/write access, and this review is not deployment authorization. The deployed HA timing configuration should still be checked before rollout because previously accepted unsafe overrides will now cause an intentional boot failure.

@dcccrypto

Copy link
Copy Markdown
Owner Author

Thank you — that's the third of three, and the most thorough of them. Reproducing 7 failed / 4 passed → 11 passed and identifying the four as the intentional "must not throw" guards is exactly the check that matters, and validating from a fresh isolated worktree rules out the stale-node_modules trap this repo has bitten me with before.

Two things I want to acknowledge properly rather than let pass:

1. You engaged the fail-fast trade-off rather than just the code. That's what I most wanted, since the diff is small and the trade is where the risk actually sits. And you caught the deployment consequence independently — "previously accepted unsafe overrides will now cause an intentional boot failure" — which is the real operational risk here, not the validation logic.

2. "I do not have merge/write access." That's genuinely useful and corrects a wrong assumption of mine. On #386 I asked whether you'd merge it yourself if comfortable — that was never actionable, and I'm sorry for putting you in the position of having to decline something you couldn't do. I'll stop suggesting it.

So the state is now unambiguous: #382, #385 and #386 are all reviewed and approved, and every remaining step needs someone with write access. Nothing further is blocked on analysis, testing, or review.

For whoever does the merges, the caveats worth carrying over:

Appreciate the three reviews — that's three more than anyone else, and each one found or confirmed something I'd have wanted found.

@dcccrypto

Copy link
Copy Markdown
Owner Author

Independent verification — not an approval (QA/Security own that), just evidence for whoever reviews. Verdict: genuine, and one of the better-built PRs I've checked.

Method: ran the PR's own tests first (11 passed), counted the mechanisms — one validateTiming with two throw conditions plus a non-fatal thin-margin warning — then neutralised the call:

validateTiming(ttlMs, renewMs, pollMs);      // removed

Result:

guard present → 11 passed
guard removed →  7 FAILED

and they're the right seven: the issue's PoC config, the exact renewMs === ttlMs boundary, NaN / Infinity / zero / negative for each field, and the error-message-names-the-field case. The four that stay green cover valid configs, defaults, and the thin-margin warning — behaviour that legitimately shouldn't move.

Two things I checked beyond the mutation

1. The throw actually surfaces. A guard that something swallows is no guard, so I traced the call site. LeaderLock is constructed at module top level in src/index.ts:112-118 and is not wrapped in try/catch — a bad config aborts startup. That's the right posture here: a keeper that refuses to boot is strictly better than two keepers both submitting on-chain transactions.

2. The empty-string env case is covered, and it's a real one. The call site uses:

ttlMs: Number(process.env.KEEPER_LEADER_LOCK_TTL_MS ?? 30_000)

?? only catches null/undefined, so an env var set but empty (KEEPER_LEADER_LOCK_TTL_MS=) gives Number("")0, and a non-numeric value gives NaN. Both are exactly what the new positivity/finiteness checks reject — so this fix also closes a misconfiguration that would previously have produced a zero or NaN TTL. Worth knowing that's covered, because it's the failure mode an operator is most likely to hit by accident.

On the warning

Making the thin-margin case a logger.warn rather than a throw looks right — renewMs > ttlMs/2 is risky under a slow Redis round-trip but not deterministically split-brain, so refusing to boot would be too aggressive. Good that accepts a thin renew margin but does not silently endorse it pins it rather than leaving it untested.

No changes requested from me.

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.

2 participants