Skip to content

feat(server): let liveSubscriptionAuth revoke a single subscriber without ending its subscription - #2039

Merged
kriszyp merged 19 commits into
mainfrom
fix/live-subscription-revoke-seam
Aug 26, 2026
Merged

feat(server): let liveSubscriptionAuth revoke a single subscriber without ending its subscription#2039
kriszyp merged 19 commits into
mainfrom
fix/live-subscription-revoke-seam

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

registerLiveSubscription (the #1414 continuous re-authorization registry) now accepts an optional revoke?: () => void | Promise<void>, used as the entry's terminate instead of the hard-coded end()/close()/emit('close') on the subscription object, and returns an { unregister } handle. When revoke is supplied the subscription object is left completely untouched — no end wrapping, no 'close' listener — and the caller unregisters through the returned handle instead.

Why: profiling a production node found ~157,888 live Subscription objects across only 29 distinct topics, almost entirely per-connection bookkeeping (each carries its own DatabaseTransaction, TableResource, Generator). The fix is to share one feed per topic across its subscribers while keeping authorization per subscriber — but revocation today works by destroying the subscription object, which is exactly wrong once that object is shared: wiring terminate to a shared feed would disconnect every subscriber on one user's revocation or token expiry (and, since sweep() fails closed on recheck errors, one transient error would tear the feed down for everyone). This PR is only the prerequisite seam — no shared feeds, no refcounting, no subscriber-list machinery.

Revocation granularity must stay per subscriber even once feed granularity becomes per topic — that's the invariant this seam exists to establish.

Scope reduced from the earlier revision

Earlier revisions of this branch also shipped a revocation state machine — pendingTerminate, a bounded terminate timeout, settle-handler late-commit, and fail-closed retry across sweeps — roughly 120 lines whose only exercise was its own tests, since nothing in this repo supplies revoke. That is now removed, per the owner's decision on review feedback ("reduce scope"): the timeout and idempotency requirements are unknowable without a real caller, and the shared-feed follow-up may want different ones.

What remains is the seam plus the one change the seam genuinely forces: terminate may now return a promise, so terminateEntry untracks the entry first and dispatches teardown once, fire-and-forget, containing a synchronous throw and a rejection alike. Untracking first is what keeps a hung revoke from wedging the sweep — awaiting it would hold the sweeping mutex forever — and it matches the pre-existing default-path behavior, which also deleted the entry before calling terminate(). The consequence, stated in the contract: a revoke that throws, rejects or never settles is logged and never retried.

Three smaller changes came out of this branch's review rounds:

  • Revocation logging is two-level. Each revocation keeps its own info line, and every sweep that revoked anything closes with one aggregate warn carrying the total and a per-reason breakdown. Harper ships logging.level: warn (static/defaultConfig.yaml:46), so an info-only line would make every expected revocation invisible on a default deployment; a warn per subscriber would turn one mass role change into a 10k-line storm. The aggregate is emitted from sweep()'s finally, and it says revoking, not revoked, because terminate is dispatched fire-and-forget — a teardown that fails still gets its own error line. warn also still covers the per-entry recheck-failure path it covered on main.
  • The unit suite pins HARPER_SUBSCRIPTION_REAUTH_INTERVAL_MS before importing the module. It previously ran against the real 30s interval, where a background tick makes _sweepNow() a no-op through the sweeping guard and assertions then run against a sweep that never happened.

When revoke is omitted, behavior is unchanged from #1414 in every respect except the new revocation logging; the sole existing caller (registerLiveSubscriptionForContext in resources/Resource.ts) doesn't pass revoke.

For the human reviewer

The load-bearing judgment call is fail-open teardown: with the state machine gone, a revoke that never settles leaves a deauthorized subscriber attached with no further authorization evaluation. Reviewers raised this in every round. It is documented on the API and cannot manifest on this branch (no caller supplies revoke), and it is deliberately the shared-feed caller's problem to solve where the timeout/idempotency semantics are actually known — but it should be ratified before that wiring lands, and it is cheap to revisit now while the seam is unused.

Declined this round, all pre-existing on main and out of scope for a scope-reduction change — each is worth its own issue:

  • sweep() is N serialized recheck() round-trips behind one global mutex (adjudicated major). The production recheck does a findAndValidateUser + allowRead per entry with no memoization across entries sharing a username, and there is no timeout on it. This seam does raise registry cardinality from per-stream-object to per-subscriber, which makes N bigger — but the serialization, the mutex, and the unbounded await are all unchanged from main.
  • ITC-triggered sweeps are dropped with no trailing pass: if (sweeping) return discards a user-change broadcast arriving mid-sweep, so revocation falls back to the 30s backstop. A dirty flag re-run in sweep()'s finally closes it. (One reviewer described the exposure as "up to 24 hours" — that reads the test interval override; production is the 30s backstop.)
  • The recheck-failure path still warns once per entry, as it does on main — each of those lines carries a distinct error message, so collapsing it into the aggregate would cost diagnostics rather than noise. The aggregate counts those entries too, under recheck error.
  • A leaked 'close' listener and the never-restored end wrapper on the default path: unregister() removes the registry entry but does not removeListener('close', …) or put back the original end. Pre-existing from Live subscriptions (SSE/MQTT/WS) continue delivering events after drop_user / role revocation — stale-auth leak #1414 and harmless for a per-connection subscription object that is discarded on close, but it would matter for a pooled or reused stream object.
  • Two-mode discriminated union vs two named entry points, and _sweepNow/_liveSubscriptionCount exported from the production module — both flagged as API-shape decisions, both cheapest to change now.

Refuted, not declined: the last review round's remaining "major" claims hdbLogger.info/warn/error are unbound methods, so passing them as references (safeLog(notice, …), notice = hdbLogger.info) throws a this TypeError that safeLog swallows, silently suppressing every revocation log. They are module-level free functions — export function info(...args) { mainLogger.info(...args); } at utility/logging/harper_logger.ts:807, :825, :861 — with no this reference, so a bare reference is safe, and the suite's logs each expected revocation at info and one aggregate warn per sweep test asserts the lines are actually emitted. The Human-Review-Need: 4 footer below still counts it: that round's adjudication leg was auto-pruned as a narrow low-risk delta, so the grade is "outside findings unadjudicated", not a live defect.

Verification

  • npm run build, npm run lint:required, npm run format:write — clean.
  • npx mocha "unitTests/server/**/*.test.js" — 715 passing, including the 20 tests in this file. Two of them cover the aggregate: one info line per subscriber plus exactly one warn with the count and reasons, and no aggregate at all for a pass that revokes nothing.
  • End-to-end route: existing integration suite. npm run test:integration -- integrationTests/security/subscription-revocation.test.ts — 6/6 passing, unmodified, covering SSE/WS/MQTT revocation on drop_user, alter_role, and token expiry. This is what proves the sole in-repo caller is unaffected; the revoke path itself has no end-to-end route until the shared-feed caller lands, and is covered only by unit tests (rejection containment, hang containment, no subscription mutation).
  • npm run test:unit:main and npm run test:unit:resources both have failures on this machine that reproduce identically with origin/main's copy of both changed files: test:unit:main aborts at import with LZ4 not supported in this build against a stale local database dir, and test:unit:resources fails 3 tests in replayStructures / randomAccessFields, neither of which imports this module.

Refs #1414


Generated with Claude Opus 5.

🤖 Generated with Claude Code

Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=8 @ 096f399

Human-Review-Need: 4 @ 096f399

kriszyp and others added 9 commits July 31, 2026 18:24
…hout ending its subscription

registerLiveSubscription now accepts an optional `revoke?: () => void`, used as the
entry's terminate instead of the hard-coded end()/close()/emit('close') on the
subscription. It also returns an `{ unregister }` handle; when `revoke` is supplied
the subscription object is left untouched (no end() wrapping, no 'close' listener) —
the caller owns unregistration.

This is the prerequisite seam for sharing one live-subscription feed per topic across
its subscribers (a production node held 157,888 Subscription objects across 29
topics purely for per-connection bookkeeping). Wiring terminate to a shared feed
today would disconnect every subscriber on one user's revocation or token expiry;
this seam keeps revocation per-subscriber while feed sharing lands in a follow-up.

When `revoke` is omitted, behavior is byte-for-byte #1414: default terminate and the
existing end-wrapping/'close' self-wiring are unchanged, covered by regression tests.

Refs #1414

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tests

Independent pre-push review (codex+gemini+grok+harper-domain, adjudicated) found two
related majors in the seam: a custom `revoke` that throws was invoked a second time by
sweep()'s fail-closed catch (the default terminate already caught its own errors, so this
was latent but harmless before; a caller's non-idempotent revoke — e.g. a shared-feed
refcount decrement — makes it consequential). And `unregister()` racing an in-flight
`recheck()` was invisible to sweep, so terminate/revoke could still fire on an entry the
caller had already torn down itself.

Both share one missing invariant: terminate/revoke must run at most once per entry, and
only while that entry is still registered. Enforce it with `claimAndTerminate`, using
`Set.delete`'s boolean return as a lock-free claim token — no change to the recheck
contract, fail-closed behavior, or interval/ITC triggers. Also gives terminate failures
their own log message instead of being misattributed to "recheck error".

Also: unitTests/server/liveSubscriptionAuth.test.js used sinon, which AGENTS.md forbids
for new test files (unitTests/server/ has older sinon-based tests but is not the target
shape). Replaced with a plain call-recording closure and added regression tests for both
majors above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-2 pre-push review (codex+gemini+harper-domain, adjudicated) found that
`revoke?: () => void` type-checks against an `async () => {...}` callback, but
claimAndTerminate's `try { entry.terminate() } catch` only ever caught a synchronous
throw — a rejected promise from an async revoke (the realistic shape for the
shared-feed use case this seam exists for, e.g. an awaited refcount release against a
backing store) would escape as an unhandled rejection and take down the whole worker,
dropping every unrelated connection on it. There's no production unhandledRejection
handler to fall back on.

Widen `terminate`/`revoke` to `() => void | Promise<void>` and `await` inside
claimAndTerminate's try/catch so a rejection is caught the same way a synchronous
throw already was. Also stop logging non-Error throws as "undefined" (a plain string
or object thrown by caller code is plausible, and swallowed the actual cause) via a
small errorMessage() helper, applied to both the terminate-failure and recheck-error
log sites this diff already touches. Added a docblock note on the two invariants a
`revoke` caller must uphold that this module can't enforce: owning the entry's full
lifetime, and not mutating recheck-shared state across subscribers on one feed.

Added a regression test for the rejecting-async-revoke case: the module-level
unhandledRejection handler in unitTests/testUtils.js would fail the whole suite were
this not fixed, so its passing is itself part of the proof.

Two review findings deliberately NOT addressed here, as out of scope for this seam
(flagged in the PR description instead): retrying a failed terminate (currently the
entry is dropped from tracking on the first failure — a design question for whoever
builds the shared-feed follow-up, not fixable without restructuring sweep()'s
iteration and risking new Set-mid-iteration bugs), and an AbortSignal-based backstop
for a caller that forgets unregister() (new API surface beyond this seam's scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g tracking

Round-3 delta review (codex+gemini+grok+harper-domain, adjudicated across two
consecutive rounds) escalated a finding first raised as moderate: claimAndTerminate
used registry.delete()'s boolean return as both the claim AND the commit, so a
revoke/terminate that throws or rejects still permanently removed the entry from
the registry — the deauthorized subscriber keeps receiving data indefinitely, with
only a log line as any trace. That's fail-open dressed as fail-closed.

Split the claim from the commit: claimAndTerminate now checks `registry.has(entry)`
(the caller hasn't already unregistered it) and only calls `registry.delete()` AFTER
`terminate()` resolves successfully. On failure the entry is left exactly where it
was in the Set — not re-added, so it can't be revisited within the same sweep pass
(the delete-then-readd shape that would risk that was explicitly avoided) — and is
picked up again on the next interval tick or ITC event. Since recheck() will fail
the same way next time, retry is naturally bounded and self-terminating once
teardown actually succeeds.

This also fixes, for free, the round-2 minor about the recheck-error log line
asserting a revocation that didn't happen: claimAndTerminate's return value is now
strictly "did I just tear this down," so callers only log success when it's true.
Consolidated the two ad-hoc log call sites into claimAndTerminate itself, at
`error` (an actual failed security control, not a warning) for a failed attempt and
`info` for a successful one — closing another minor about revocations never
appearing in the log on the normal path.

Updated the two tests that pinned the old drop-on-failure behavior to instead prove
retry-until-success: revoke fails once, the entry stays registered and revoke is
not called on other subscribers sharing the object, then a second sweep retries and
the entry is removed once revoke actually succeeds.

Two related findings deliberately left as documented, out-of-scope risk (called out
explicitly in both this and the prior round's review as pre-existing, not introduced
by this diff): an unbounded await inside sweep()'s serialized loop can wedge
`sweeping` forever if a caller's recheck or terminate never settles (recheck already
had this property before this PR); and the sweeping-reentrancy guard drops a
concurrent ITC broadcast rather than deferring it. Both would require restructuring
sweep()'s timing/reentrancy semantics, which this seam-only PR's brief explicitly
excludes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-4 full review (codex+gemini+grok+harper-domain, adjudicated) confirmed as
major, across all four lenses: sweep()'s reentrancy latch (`sweeping`) is only
cleared in a `finally` that never runs if an awaited call inside the loop never
settles. `await entry.recheck()` already had this shape, but it calls
registry-controlled Harper code (findAndValidateUser); `await entry.terminate()` is
now arbitrary caller code (a shared-feed refcount release against a store that can
wedge), which is a materially wider hole. A revoke that never settles would silently
disable continuous re-authorization for every subscription on the worker, forever,
with no log line.

Race terminate() against a bounded timeout (5s default, env-overridable via
HARPER_SUBSCRIPTION_TERMINATE_TIMEOUT_MS, read fresh per call so tests can override
it without a require-cache reset) and treat a timeout as a terminate failure — same
fail-closed retry claimAndTerminate already does for a throw/rejection. Deliberately
does NOT touch recheck()'s await: that's pre-existing, registry-controlled, and
outside this seam's brief.

Also: made errorMessage() itself defensive (a thrown value whose own String()
conversion throws — e.g. Object.create(null) — must not turn a contained failure
into a new one propagating out of a catch handler with no outer guard), and added a
third documented invariant a `revoke` caller must uphold: idempotency, since a
failed attempt is retried and the registry.has() claim check only closes the
pre-await race, not a caller's own teardown running concurrently with an in-flight
revoke.

Added a regression test: a subscription sharing an object with a hung revoke and a
normal one — the hung entry doesn't block the normal one in the same sweep pass,
and stays registered for retry rather than wedging sweep() indefinitely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation; dedupe retries of a still-pending revoke

Round-5 delta review (codex+gemini+grok+harper-domain, adjudicated) found two more
concrete bugs:

1. registerLiveSubscription's validity guard (`!subscription || ... || subscription.closed`)
   ran unconditionally, even though the `revoke` path never reads `subscription` at all.
   A shared-feed caller with no meaningful per-subscriber subscription object — the
   expected shape for this seam — would silently get NOOP_HANDLE back, indistinguishable
   from a real registration, and that subscriber would never be re-authorized again. The
   guard is now skipped when `revoke` is supplied; it still applies in full to the
   default (registry-owned) path, unchanged.

2. The timeout added last round bounds how long a hung revoke can block one sweep, but
   didn't stop the NEXT sweep from invoking the same still-hanging revoke a second time
   (and a third, ...) — an unbounded pile of concurrent in-flight calls to a non-idempotent
   shared-feed teardown (e.g. a refcount decrement) rather than one contained hang.
   claimAndTerminate now caches the in-flight attempt on the entry (`pendingTerminate`) and
   reuses it across retries instead of calling terminate()/revoke() again, clearing the
   cache only once that specific attempt actually settles (success or failure) so a merely
   slow call gets retried fresh once it's actually done, while a genuinely stuck one is
   never re-invoked.

Also, two cheap defensive fixes flagged as real but minor: `terminateTimeoutMs()` was read
twice per attempt (now once), and the two `void sweep()` fire-and-forget trigger sites (the
interval and the ITC listener) had no `.catch()` — a throw from claimAndTerminate's fail-
closed catch arm (e.g. a broken logger) would have escaped as an unhandled rejection.

Added tests for both fixes: registering with revoke against a null/undefined/closed
subscription still tracks and revokes correctly; a revoke that stays pending across two
sweep passes is invoked exactly once, and committing its eventual resolution never
re-invokes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… the racing sweep

Round-6 delta review found a real bug in last round's fix, with a standalone repro:
a revoke that times out (still pending) and then succeeds BETWEEN sweeps — after the
timeout window closes but before the next sweep starts — was silently discarded.
claimAndTerminate's own commit logic (registry.delete + success log) only ran after
its own `await withTimeout(...)` resolved; if nothing was actively awaiting the
cached attempt when it finally settled, that success was lost, and the next sweep,
seeing `pendingTerminate` already cleared by the settle handler, invoked `revoke`
again — exactly the double-invocation this cache was built to prevent, just shifted
to a different timing window. The included test for this only passed because
`resolveRevoke()` and the following `_sweepNow()` ran in the same synchronous turn in
the test, which isn't representative of the real ~30s gap between sweeps.

Fixed by moving the commit (and the failure log) into the handler attached to the
cached attempt itself, at the point it's created — that handler fires whenever the
attempt settles, whether or not a sweep happens to be actively racing it via
`withTimeout` at that moment. `claimAndTerminate`'s own post-race code path no longer
does any committing; it just reports whether ITS wait succeeded. A timeout with the
attempt still pending logs a distinct "still pending, will retry" message so a
persistently hung revoke doesn't go completely silent, without double-logging once
the attempt's own handler has already logged its definitive outcome.

Hardened the affected test to resolve the revoke and yield via `setImmediate` with no
active sweep, asserting the entry is removed before any further sweep runs — this is
the shape that would have caught the bug the same-turn version missed.

Also (real but minor, cheap to close): wrapped every `hdbLogger.*` call this file
makes in a new `safeLog()` helper. `hdbLogger.error` sits on top of an unguarded
`fs.appendFileSync`; a throw from a log call inside the fail-closed catch arm would
otherwise abort the sweep pass at that entry (leaving every entry after it
unauthorized-but-still-delivering for the rest of that pass) and then escape
`triggerSweep`'s own handler as an unhandled rejection.

Two items raised again this round were deliberately NOT changed, with reasons now
made explicit for the PR:
- Bounding `recheck()` with the same timeout, as suggested, would change behavior on
  the DEFAULT (no-`revoke`) path — a legitimately slow-but-correct recheck under load
  could get spuriously treated as unauthorized — which conflicts directly with this
  seam's own acceptance criterion that revoke-absent behavior stay identical to #1414
  in every respect. Bounding `terminate` is safe because it only ever engages on the
  brand-new, currently-unused `revoke` path; bounding `recheck` is not.
- Serial per-entry timeout accumulation and the sweeping-guard dropping a concurrent
  ITC broadcast were reassessed this round as a latency regression bounded by the 30s
  interval backstop, not a re-authorization hole — deferred to whoever designs the
  real shared-feed sweep architecture, which is explicitly out of this task's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…top re-racing an already-pending revoke

Round-7 delta review found the success log added two commits ago never actually fires
on the DEFAULT (no-`revoke`) path — the only path any production caller uses today.
The settle handler gated the log on `if (registry.delete(entry))`, but on the default
path `entry.terminate()` calls `subscription.end()`, which is the registry's own
wrapper — it synchronously calls `unregister()` (`registry.delete(entry)`) before the
settle handler's microtask ever runs. By the time that handler checks, the entry is
already gone, `registry.delete` returns false, and the log is skipped — silently, on
every single default-path revocation. Since claimAndTerminate is the sole place that
commits a successful termination, and an attempt's settle handler is only ever
attached once per attempt, there was never a double-log risk to guard against; the
guard was removed.

Second finding: since the settle handler (not the racing `await` in claimAndTerminate)
is what commits an outcome, re-racing an entry whose attempt is already in flight from
a prior sweep serves no purpose — the boolean it produces isn't used by anything. It
only costs a full `terminateTimeoutMs()` of serialized sweep time per stuck entry, on
every single sweep pass, forever, while the entry accumulates more never-firing
`.then()` reactions on the same hung promise. `claimAndTerminate` now returns
immediately (no wait, no new log) when it finds `entry.pendingTerminate` already set —
only the FIRST attempt for a given entry is ever raced against the timeout; every
outcome, however it eventually arrives, is still handled by that first attempt's
settle handler.

Added a regression test asserting the default (`end()`-wrapping) path actually emits
the info log — this would have failed against the previous two commits. Added a
timing assertion to the existing pending-revoke test proving a sweep that finds an
already-pending attempt returns near-instantly rather than re-racing the timeout.

Two more re-raised findings again declined, unchanged from the reasoning already on
record: bounding `recheck()` the same way would change default-path behavior,
conflicting with this seam's own acceptance criterion that revoke-absent behavior
stay byte-for-byte #1414; and an escalation ceiling / hard-teardown fallback for a
permanently-failing `revoke` contradicts the seam's purpose (never end a shared feed
over one subscriber's failure) — both are design questions for the shared-feed
follow-up PR, not defects in this one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a recheck on it; honest success wording

Round-8 delta review found that last round's "don't re-race an already-pending
attempt" fix, while correct on its own terms, had two side effects that made a
permanently-stuck revoke strictly worse to operate against than before:

1. A revoke that never settles now produces exactly ONE log line, ever, then goes
   completely silent for the rest of the worker's life — worse than the previous
   round's "log every sweep" behavior, even though that behavior was also
   functionally inert (nothing was actually being retried). Restored a cheap,
   no-wait log on every sweep that finds an already-pending attempt, so a
   persistently stuck entry stays visible without reintroducing the timeout cost
   the previous fix removed.

2. `sweep()` still ran a full `recheck()` — in production a storage read
   (`findAndValidateUser`) plus user-overridable `allowRead` — for every entry whose
   terminate is already known to be pending, every single sweep, for as long as it
   stays stuck. `sweep()` now skips straight to `claimAndTerminate` (which just logs
   and returns) for an entry with `pendingTerminate` already set, and only computes
   `Date.now()` per entry rather than once before the loop, so a slow entry earlier
   in a pass can't make a later entry's expiry check stale (pre-existing #1414
   shape, cheap to correct now that per-entry timeouts can stretch a pass).

Also fixed, confirmed via the reviewer's probe: on the DEFAULT (no-`revoke`) path, a
throwing `end()`/`close()` is swallowed internally (unchanged #1414 behavior) but the
settle handler still logged "revoked subscription" as if delivery were confirmed
stopped. Reworded the success log to "terminate completed" — honest about what this
module actually knows (terminate ran without an error reaching it), not a claim about
delivery state it can't verify on the swallow-everything legacy path.

Replaced a `<15ms` wall-clock assertion (AGENTS.md names this exact pattern as a
flakiness root cause) with a deterministic race: raise the timeout to 100s and race
the sweep against a short marker — an implementation that re-raced the pending
attempt could not win regardless of runner load. Extended the hung-revoke test to
cover a second sweep, proving no re-invocation and the restored per-sweep log.

One more re-raised finding declined again, same reasoning as the last three rounds:
an escalation ceiling / re-invocation-after-N-sweeps for a permanently failing
`revoke` is a design decision for the shared-feed follow-up (how many sweeps, and
whether re-invoking a call that might still genuinely be in-flight is even safe,
given nothing here can distinguish "dead" from "slow"), not a defect in this seam.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@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 enhances the live subscription authorization mechanism in liveSubscriptionAuth.ts by introducing support for a caller-supplied revoke callback, allowing shared feeds to manage revocation per subscriber. It also adds robust error handling, logging safety, and timeout bounds to prevent hung terminations from blocking the sweep process. Comprehensive unit tests are added to verify these behaviors. The reviewer recommends snapshotting the registry Set into an array before iterating over it in sweep() to avoid mutating the Set during iteration.

Comment thread server/liveSubscriptionAuth.ts Outdated
Comment thread server/liveSubscriptionAuth.ts Outdated
@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review August 21, 2026 04:35
Comment thread server/liveSubscriptionAuth.ts Outdated
Comment thread unitTests/server/liveSubscriptionAuth.test.js Outdated
Comment thread server/liveSubscriptionAuth.ts Outdated
Comment thread server/liveSubscriptionAuth.ts Outdated
Comment thread server/liveSubscriptionAuth.ts Outdated
Comment thread server/liveSubscriptionAuth.ts Outdated
kriszyp and others added 2 commits August 24, 2026 16:56
Snapshot the authorization registry before sweep mutation, preserve default-level revocation visibility, clamp terminate timeouts to Node's supported range, and stop the timer after late settlement. Add realistic JWT expiry coverage and regression tests for the new guards.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Remove contradictory reviewer-directed narration, make the timeout log accurately describe wait-only handling for hung attempts, and make the timeout-clamp assertion robust to unrelated timers.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread server/liveSubscriptionAuth.ts Outdated
@kriszyp

kriszyp commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

CI triage: all three failures were unrelated to this PR — checks are green now

Re-ran the failed jobs on the identical commit a10594b2 with no code change; every check now passes (42 pass / 2 skipping).

Failing check Actual failure Verdict
Unit Test (Node.js v22) Txn Expiration > Slow txn will expireunitTests/resources/txn-tracking.test.js:61 Flake
Integration 2/6 (Windows) describe-metadata-upgrade.test.ts — probe /SeoPageCache/ never ready 120s after restart_service Known Windows CI issue
Integration 6/6 (Windows) configuration.test.mjs:319 — 500 from set_configuration, EPERM: rename …harper-config.yaml.<pid>.<tid>.<hex>.tmp -> harper-config.yaml Known Windows CI issue

Why none of them can be this diff:

  • The diff is confined to server/liveSubscriptionAuth.ts + unitTests/server/liveSubscriptionAuth.test.js. Nothing in it touches transaction tracking, component install, HTTP-worker restart, or the config write path.
  • The unit failure is in a different mocha process from the new test: CI runs test:unit:all, where liveSubscriptionAuth.test.js lands in test:unit:main and txn-tracking.test.js in test:unit:resources. No shared process, so no cross-test timing interference is possible. main itself failed Unit Test in 3 of its last 8 runs, each time on a different single test (Caching > Can load cached data, HNSW greedy routing above layer 0, MQTT QoS=1 reconnect) — same flake profile.
  • Windows shard 2's failure is the same shape as #2273 (Windows component-deploy/restart hang after npm pack): npm install took 4 minutes on that runner, the restart's readiness probe then got ECONNREFUSED for its whole 120s window.
  • Windows shard 6's EPERM rename reproduced identically on an unrelated PR in the same window — kris/642-branched-databases run 32790110806, same test, same assertion. fix/bounded-update-attributes-lock was failing the same two Windows shards across four runs tonight. It is org-wide, not branch-specific.

Verified locally on this branch: unitTests/server/liveSubscriptionAuth.test.js 21/21 passing, the whole top-level unitTests/server/ suite 240/240, and txn-tracking.test.js green on 3 consecutive runs.

No code change was needed, so nothing was pushed — the branch is still at a10594b2.

Two things worth a separate look, both pre-existing and outside this PR:

  1. The Windows EPERM on atomicWriteFile's rename (config/configUtils.ts:110) already has an 8-attempt backoff and still exhausts it — several threads write harper-config.yaml concurrently on set_configuration, and on Windows a rename over a file another thread holds open fails. I did not find an existing issue for this one, unlike #2273.
  2. npm run test:unit:server is broken on main independently of this PR: its unitTests/server/**/*.js glob loads worker fixtures as test files and dies on processGroupOwnerWorker.js:15 (Cannot read properties of null (reading 'postMessage')). CI never notices because test:unit:all doesn't include that script.

— Claude Opus 5

kriszyp and others added 3 commits August 24, 2026 21:11
…y invariant

`claimAndTerminate`'s docblock promises "a pending attempt is not invoked
again", but the guard enforcing it was dropped in b679649, leaving the
guarantee to `sweep()`'s `continue`. Both of today's callers sit after that
`continue`, so there is no live bug — but the seam exists so harper-pro can
add callers, and an event-driven immediate revoke would call this directly
and double-invoke a `revoke` still in flight. Put the guarantee back in the
function that documents it. The log line stays in `sweep()`.

`registerLiveSubscription` invariant (3) said a failed attempt is retried on
the next sweep. That holds for a rejecting `revoke`, not for a hung one —
and a hung release is exactly the shape a shared-feed refcount decrement
against a wedged store takes, so it is the case a `revoke` author most needs
to know is not retried.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…etry invariant

Pre-push review (codex + gemini, adjudicated) found the options type still
required `subscription` even though the runtime and the tests both accept a
revoke-only registration without one — a shared-feed caller that owns no
subscription object had to pass a meaningless `subscription: undefined` to
type-check. Make it optional; the `!revoke && !subscription` guard already
handles the rest.

The domain leg also caught that the invariant sentence is still not true after
the previous commit: a *rejected* attempt is only re-invoked if the next
sweep's `recheck` again denies, so a subscription whose permission was restored
in between is left half-torn-down and read as healthy. Say that.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…wording

Round 2 of the pre-push review caught that making `subscription` plainly
optional went too far: a caller supplying neither `subscription` nor `revoke`
type-checked, got NOOP_HANDLE, and silently left its live subscription
untracked. Model the options as a union so exactly one of the two teardown
modes is required — verified that the three valid shapes compile and the
neither-mode call is a type error.

The rejected-terminate log still said "will retry next sweep" unconditionally,
which is the same overstatement just corrected in the docblock. Say "on the
next sweep that still denies".

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

kriszyp commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Follow-up: both requested one-liners applied, plus two things the pre-push review found in them

Pushed a10594b288b06112d, three commits. @kriszyp asked for the two changes @cb1kenobi requested; the independent pre-push review (Codex + Gemini + Cursor Grok + Harper domain) then found two real problems in my own first pass, so those are fixed too.

Requested — 1ce07e5c4

  • Restored if (entry.pendingTerminate) return false; at the top of claimAndTerminate, so the at-most-once guarantee lives in the function whose docblock promises it rather than only in sweep()'s continue. Both of today's callers already sit after that continue, so behavior is unchanged; a future direct caller — the event-driven immediate revoke harper-pro will want — no longer double-invokes an in-flight revoke.
  • Qualified registerLiveSubscription invariant (3): a hung revoke is not retried and the entry is not rechecked again.

Found by the review, in my own fixes — ccfcb2117, 88b06112d

  • The options type still required subscription, so a revoke-only caller owning no subscription object — the whole point of the seam — had to pass a meaningless subscription: undefined to type-check. My first attempt made it plainly optional, which went too far: a caller supplying neither subscription nor revoke then type-checked, received NOOP_HANDLE, and silently left its subscription untracked. It is now a union requiring exactly one teardown mode. Verified the three valid shapes compile and the neither-mode call is a TS2345 error, with repo typecheck clean (the existing resources/Resource.ts:950 caller still matches).
  • The rejected-terminate log still said "will retry next sweep" unconditionally — the same overstatement just corrected in the docblock, since a rejected attempt is only re-invoked if the next sweep's recheck again denies. Now "on the next sweep that still denies", and the invariant says so too: a subscription whose permission was restored in between is left half-torn-down and read as healthy.

Verification: npm run build and npm run typecheck clean; unitTests/server/ 240/240 (liveSubscriptionAuth 21/21); Prettier clean. No test changed — none of this alters behavior for any caller in the tree.

For the human reviewer — deliberately not addressed

The review re-raised these across three rounds and I left every one of them alone: they are design decisions on your PR, not defects I should be resolving unsupervised. Round 3 confirmed the delta "introduces no new correctness issue"; these are all carried forward from the original diff.

Decision
major A never-settling revoke turns fail-closed into fail-open: the entry is skipped by every later sweep, recheck never runs again, and nothing proves delivery stopped. The docblock now states this as intended semantics — that is the part worth ratifying explicitly. This is the same ground as @cb1kenobi's still-open thread, which suggests bounding the entry (clear pendingTerminate after N sweeps) rather than the attempt.
major Serial per-entry timeouts extend the exclusive sweep window, and sweep()'s if (sweeping) return drops an ITC trigger firing inside it with no trailing sweep. 20 hung revokes ⇒ ~100 s window; a drop_user landing in it waits for the next 30 s tick. The coalescing gap pre-exists, but this makes it deterministic and multiplies it by entry count. Root fix is a dirty flag plus a trailing sweep, not more concurrency.
minor recheck is still unbounded while terminate is now timeout-bounded — and recheck is the one doing real I/O (findAndValidateUser + allowRead). withTimeout already exists here.
minor Mass revocation on the default path now emits one warn per subscriber (10k on a role drop) and allocates per entry; a permanently stuck entry logs at error every 30 s forever with no dedup.
minor unitTests/server/liveSubscriptionAuth.test.js — the "must not re-race the timeout" assertion races _sweepNow() against a 250 ms marker, so its own "no matter how loaded the test runner is" claim is not true. AGENTS.md names this class as the root cause of #1138. Counting setTimeout invocations, as the clamp test already does, would assert the invariant directly.
scope @cb1kenobi's open thread: land only the ~20-line seam and move the timeout / at-most-once / late-commit machinery into the PR that supplies a real revoke. Your call — I did not act on it.

Two review findings were adjudicated away rather than left open, worth recording so they don't come back: Gemini's safeLog unbound-this concern does not apply (utility/logging/harper_logger.ts:353-385 exports standalone module functions, not class methods — this is never read), and the Array.from(registry) snapshot is immaterial next to one awaited DB-backed recheck per entry in the same loop, besides buying the mid-sweep-removal semantics the tests assert.

Independent pre-push review: 3 rounds, reviewers=codex+gemini+cursor-grok+harper-domain (round 1 full), codex+gemini (rounds 2–3 delta). 4 findings fixed, 6 open by design.

— Claude Opus 5

kriszyp and others added 3 commits August 24, 2026 23:01
The pending/timeout/late-commit state machine had no caller in this repo, so
its only exercise was its own tests. Drop it: terminate is dispatched once,
best effort, after the entry is untracked, which is the pre-existing default-path
behavior extended to an async revoke. A revoke that throws, rejects or never
settles is logged and not retried — recovery belongs to the caller that supplies
revoke, where the timeout and idempotency requirements are actually known.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…t overlap

Review round 2: a per-subscriber `warn` on every revocation turns one mass role
change into a warn storm, so expected revocations (expiry, deny) log at info and
`warn` stays for the recheck-failure path it covered on main. The suite also ran
against the module's real 30s interval, where a background tick makes `_sweepNow()`
a no-op through the `sweeping` guard; pin the interval before the import.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
… leak it

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

kriszyp commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Scope reduced to the seam (option A), per the owner's decision

9ee14b5ef48484 remove the revocation state machine — pendingTerminate, the bounded terminate timeout and its env var, claimAndTerminate's settle-handler commit, and the "previously pending" sweep branch. Net −167 lines in server/liveSubscriptionAuth.ts and its test since a10594b2. What is left is exactly the seam @cb1kenobi described: accept revoke, use it as terminate, skip the subscription-object wiring, return { unregister }.

Two things came out of the pre-push review of those commits and are also in:

  • Routine revocations (expiry, deny) log at info; warn stays on the recheck-failure path it covered on main. A per-subscriber warn would turn one mass role change into a warn storm.
  • The unit suite pins HARPER_SUBSCRIPTION_REAUTH_INTERVAL_MS before importing the module, and restores the patched logger from a finally. It previously ran against the real 30s interval, where a background tick makes _sweepNow() a no-op through the sweeping guard and the assertions then run against a sweep that never happened.

One outside finding is refuted rather than declined. The last round flagged as major that hdbLogger.info/warn/error are unbound methods, so passing them by reference (safeLog(notice, …)) would throw a this TypeError that safeLog swallows, silently suppressing every revocation log. They are module-level free functions — export function info(...args) { mainLogger.info(...args); } at utility/logging/harper_logger.ts:807, :825, :861 — with no this reference, so the bare reference is safe; the logs an expected revocation at info, not warn test asserts the line is actually emitted. The Human-Review-Need: 4 footer still counts that finding because the round's adjudication leg was auto-pruned on a narrow delta, not because anything is unresolved in the code. Everything genuinely declined is listed under For the human reviewer in the description, with why.

Verification on this head: all CI checks green; locally npx mocha "unitTests/server/**/*.test.js" → 714 passing, 19 of them in liveSubscriptionAuth.test.js.


Claude Opus 5

Comment thread server/liveSubscriptionAuth.ts
…t log level

Harper ships logging.level: warn, so the per-subscriber info line a routine
revocation emits is dropped on a default deployment — an expiry or a role
change tearing down live streams left no trace in the log an operator reads.
Keeping the detail at info avoids a warn per subscriber on a mass role change,
so the pass now also emits one aggregate warn with the count and the reasons.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
terminate is fire-and-forget, so a past-tense aggregate claims more than the
sweep knows; the per-subscriber line already says "revoking", and a teardown
that fails still gets its own error line.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
@kriszyp
kriszyp merged commit 112a3cb into main Aug 26, 2026
49 checks passed
@kriszyp
kriszyp deleted the fix/live-subscription-revoke-seam branch August 26, 2026 16:24
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