| … | … | @@ -202,6 +202,15 @@ export class StateBaseV3 { |
| 202 | 202 | // hyper-fleeky-dev #99: a local identity in the roster but absent from the |
| 203 | 203 | // indexer's membership read writable=true while its writes never finalized). |
| 204 | 204 | this._selfIsAdmittedWriter = false |
| 205 | // __members__ projection is EVENT-DRIVEN + self-terminating (NOT a bounded give-up loop). The |
| 206 | // creator retains the roster (set by projectMembers) and re-projects on the promote-timer tick + |
| 207 | // every new swarm connection until __members__ is confirmed in its view. A creator that becomes |
| 208 | // writable AFTER boot (re-adopt / swarm flap) therefore still lands __members__ and can admit |
| 209 | // members — the wj62 fork root was the app's 12-try boot loop giving up before writability settled, |
| 210 | // so __members__ never landed → every admit verified verifiedRole=null → members held their own |
| 211 | // lines. See test/admission-members-projection.test.mjs. |
| 212 | this._roster = null |
| 213 | this._membersProjected = false |
| 205 | 214 | } |
| 206 | 215 | |
| 207 | 216 | // ── Lifecycle ────────────────────────────────────────────── |
| … | … | @@ -253,7 +262,7 @@ export class StateBaseV3 { |
| 253 | 262 | // Cheap when idle: _maybePromote early-returns once we're a durable indexer or |
| 254 | 263 | // while we're not (yet) an admin. unref so it never keeps the process alive. |
| 255 | 264 | if (!this._promoteTimer) { |
| 256 | | this._promoteTimer = setInterval(() => { this._maybePromote().catch(() => {}) }, this._promoteRetryMs) |
| 265 | this._promoteTimer = setInterval(() => { this._maybePromote().catch(() => {}); this._ensureMembersProjected().catch(() => {}) }, this._promoteRetryMs) |
| 257 | 266 | if (this._promoteTimer && typeof this._promoteTimer.unref === 'function') this._promoteTimer.unref() |
| 258 | 267 | } |
| 259 | 268 | } |
| … | … | @@ -381,6 +390,10 @@ export class StateBaseV3 { |
| 381 | 390 | // pear-git.js:447 ("corestore.replicate unreliable for writer cores"). |
| 382 | 391 | try { this.base.replicate(socket) } catch (err) { console.error('[state-base-v3] replicate threw:', err.message) } |
| 383 | 392 | try { this.attachSignerPresence(socket) } catch {} // ephemeral signer-presence handshake — feeds the election, appends nothing |
| 393 | // EVENT-DRIVEN __members__ projection: a peer (a member) just connected. If we're the writable |
| 394 | // creator and haven't projected the roster yet, (re)project now so its admission verifies — |
| 395 | // instead of waiting on the slow promote-timer tick. Self-terminating + idempotent. |
| 396 | this._ensureMembersProjected().catch(() => {}) |
| 384 | 397 | }) |
| 385 | 398 | } |
| 386 | 399 | this.swarm.join(this.base.discoveryKey) |
| … | … | @@ -871,11 +884,24 @@ export class StateBaseV3 { |
| 871 | 884 | |
| 872 | 885 | // ── Membership projection (cross-base sync) ──────────────── |
| 873 | 886 | // Daemon's out-of-apply watcher copies keet-room membership in so apply can |
| 887 | // Event-driven, self-terminating __members__ re-projection (mirrors _maybePromote). Fires on the |
| 888 | // promote-timer tick + every new swarm connection. If we retain a roster (set by projectMembers), |
| 889 | // haven't confirmed __members__ yet, and are writable, project it now; once landed it early-returns |
| 890 | // forever (cheap when done). This is the fix for the wj62 fork: it replaces the app's bounded 12-try |
| 891 | // boot loop, so a creator that becomes writable AFTER boot still lands __members__ and admits members. |
| 892 | // No hang: projectMembers's base.update() drains OUR OWN writable base (clean); a non-writable device |
| 893 | // no-ops here and retries on the next event. |
| 894 | async _ensureMembersProjected () { |
| 895 | if (this._membersProjected || !this._roster || !this._ready || !this.base || !this.base.writable) return |
| 896 | try { await this.projectMembers(this._roster) } catch {} |
| 897 | } |
| 898 | |
| 874 | 899 | // gate admission deterministically. Stored under the reserved key __members__ |
| 875 | 900 | // (a memberId-hex → role map). No-op until we're a writer (another writer |
| 876 | 901 | // projects; peers replicate it). |
| 877 | 902 | async projectMembers (members) { |
| 878 | | if (!this._ready || !this.base || !this.base.writable) return false |
| 903 | if (members) this._roster = members // retain the roster for event-driven, self-terminating re-projection |
| 904 | if (!this._ready || !this.base || !this.base.writable) return false // not writable yet → a later event retries |
| 879 | 905 | const map = {} // memberKey(hex) -> role |
| 880 | 906 | const devmap = {} // keet writer/device key(hex) -> memberKey(hex) (for admission model A) |
| 881 | 907 | if (Array.isArray(members)) { |
| … | … | @@ -892,6 +918,7 @@ export class StateBaseV3 { |
| 892 | 918 | await this.base.append(encode('@state/set-entry', { key: '__members__', value: JSON.stringify(map), ts: Date.now() })) |
| 893 | 919 | await this.base.append(encode('@state/set-entry', { key: '__devices__', value: JSON.stringify(devmap), ts: Date.now() })) |
| 894 | 920 | await this.base.update() |
| 921 | this._membersProjected = true // __members__ appended + drained into our view → stop re-projecting (early-returns cheaply) |
| 895 | 922 | // P3 (#103): a re-projection is the moment a role change becomes visible. If THIS |
| 896 | 923 | // node was just granted admin, re-check promotion immediately rather than waiting |
| 897 | 924 | // for the next timer tick. Background + best-effort; the timer is the general |