OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / abea4d1

signer-presence: onChange event → zero-timer failover (0.2.7)
Step 1(c). SignerPresence.onChange fires on an actual co-owner connect/disconnect; StateBase routes it through _fireWatchers (the same fan-out as a view apply). So one sb.watch(fn) subscription observes writes, replicated commits, AND signer-presence changes — enabling the daemon to drop its 2s sign poll for a purely event-driven loop. Grace (fork-safety) is unchanged; grace expiry is picked up opportunistically on the next event (zero-timer design). markPresent/markAbsent fire only on real transitions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
abea4d12e58713b8f0f96c52ad2f135d62ad8417
by plankatron <fleeky@gmail.com> · 2026-07-20T03:05:31.000Z

Changed files (3) +28 -8

lib/signer-presence.js +18 -5
……@@ -21,23 +21,36 @@
2121export class SignerPresence {
2222 // self: this device's member key (hex). graceMs: how long a departed co-owner stays eligible (blip
2323 // tolerance / failover delay). clock: injectable now() for deterministic tests.
24 constructor ({ self, graceMs = 15000, clock = () => Date.now() } = {}) {
24 // onChange: fired on an ACTUAL presence change (a co-owner connected or departed) — the EVENT that
25 // drives zero-timer failover: the daemon re-evaluates holdsSigner + signs on this, plus on every
26 // StateBase advance (sb.watch), so no polling timer is needed. Grace expiry itself has no event (a
27 // write/replication/presence event after grace picks it up — acceptable failover latency, no loss).
28 constructor ({ self, graceMs = 15000, clock = () => Date.now(), onChange = null } = {}) {
2529 if (!self) throw new Error('SignerPresence: self (this device member key) required')
2630 this.self = String(self)
2731 this.graceMs = graceMs
2832 this.clock = clock
33 this.onChange = onChange
2934 this._present = new Map() // memberKey -> lastSeen: Infinity = connected now; a number = ms it departed
3035 this._present.set(this.self, Infinity) // we are always present to ourselves
3136 }
3237
33 // A co-owner (holds the signing key + is attested) is connected. Idempotent.
34 markPresent (memberKey) { this._present.set(String(memberKey), Infinity) }
38 _fire () { if (this.onChange) { try { this.onChange() } catch {} } }
39
40 // A co-owner (holds the signing key + is attested) is connected. Idempotent. Fires onChange only on
41 // an actual transition (not-present/departed -> present) so the daemon re-evaluates the signer.
42 markPresent (memberKey) {
43 const k = String(memberKey)
44 if (this._present.get(k) === Infinity) return // already connected — no change
45 this._present.set(k, Infinity)
46 this._fire()
47 }
3548
3649 // A co-owner disconnected — start its grace countdown (do NOT drop it immediately; a blip must not fail
37 // the signer over). No-op for a key we never saw.
50 // the signer over). No-op for a key we never saw. Fires onChange so a departure is noticed event-driven.
3851 markAbsent (memberKey) {
3952 const k = String(memberKey)
40 if (this._present.has(k) && this._present.get(k) === Infinity) this._present.set(k, this.clock())
53 if (this._present.has(k) && this._present.get(k) === Infinity) { this._present.set(k, this.clock()); this._fire() }
4154 }
4255
4356 // The co-owners eligible to sign right now: connected, or departed within graceMs. Sorted (stable pick).
lib/state-base.js +9 -2
……@@ -142,7 +142,10 @@ export class StateBaseV3 {
142142 // which appended to the log every ~8s forever). self = our member key; graceMs = failover blip-tolerance
143143 // (default matches the old lease TTL). Fed by attachSignerPresence() on each swarm connection; the sign
144144 // loop reads holdsSigner(). Null when we have no member identity (read-only opens).
145 this._presence = this._selfKey ? new SignerPresence({ self: this._selfKey.toString('hex'), graceMs: Number(opts.signerGraceMs) || 15000 }) : null
145 // onChange routes a presence change (co-owner connect/disconnect) through the SAME watcher fan-out
146 // as a view advance, so a single sb.watch(tick) drives ZERO-TIMER failover: the daemon re-evaluates
147 // holdsSigner + signs on writes, replication, AND presence events — no polling timer.
148 this._presence = this._selfKey ? new SignerPresence({ self: this._selfKey.toString('hex'), graceMs: Number(opts.signerGraceMs) || 15000, onChange: () => this._fireWatchers() }) : null
146149 this._getMembers = typeof opts.getMembers === 'function' ? opts.getMembers : null
147150 // Injectable crypto for membership-gated admission (keet attestation primitives).
148151 // attest: sign localKey‖base.key as our keet identity; verifyAttest: IdentityKey.verify.
……@@ -423,9 +426,13 @@ export class StateBaseV3 {
423426 try { await tx.close() } catch {}
424427 throw err
425428 }
426 for (const w of this._watchers) { try { w() } catch {} }
429 this._fireWatchers()
427430 }
428431
432 // Notify every sb.watch(fn) subscriber. Fired on a view advance (above) AND on a presence change
433 // (SignerPresence.onChange), so one subscription covers writes/replication + signer failover.
434 _fireWatchers () { for (const w of this._watchers) { try { w() } catch {} } }
435
429436 // ── Public API (generic KV — unchanged surface vs v2) ──────
430437 async set (key, value) {
431438 if (!this._ready) throw new Error('StateBaseV3 not ready')
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.6",
3 "version": "0.2.7",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",