fix(seeders): stop showing a blind peer's pair-time group count as current

A followed blind peer stayed listed forever with a group count frozen at pair
time (TODO #125). Two separate faults, worth keeping apart.

The count was a fossil. `seederFollow:` rows store `groupCount: enrolled`,
written once when the seeder was paired and never revisited, and the list
rendered it as if it were current. Observed on the TCL: "Seeding 2 groups" for a
seeder actually serving one group the device was not in.

And nothing ever said "this one serves nothing of yours". listBlindPeers already
filters group-shared `groupSeeder:` rows against live groups - the "reappears
seeding 0 groups" orphan guard - but applied no equivalent to local follows.

Fixed by deriving instead of storing: listBlindPeers now counts the live
`groupSeeder:` rows for each pubkey intersected with the currently open groups,
every time the list is read. Derived state cannot go stale. The cached
`groupCount` stays on the record for compatibility and is no longer displayed.

Marking, not hiding, per the item - silently dropping a seeder the user chose to
admit is its own confusion, so removal stays an informed choice.

The decision has THREE states, and the third is the point: a device with no
groups at all cannot say anything useful about whether a seeder serves "your
groups". The answer is vacuously no, and showing "Not seeding any of your
groups" to someone with no groups reads as a fault in the seeder. So that case
returns null and the UI stays quiet.

346 unit tests, 10 new, including the exact TCL case, the no-groups null, and
that a device WITH groups and a seeder serving none of them is still marked.

Verified against the real worklet: a follow row on a device with one live group
reports servesCurrentGroups=false and the warn label, where before it would have
rendered its stored count unchallenged. The positive path - counting live rows
for a seeder that does serve a current group - is covered by unit tests only,
since creating a groupSeeder row needs the enrol/hello path.

Only mobile renders this list; the desktop proxy has no listBlindPeers at all.
The wording lives with the decision so a future desktop list inherits it rather
than inventing its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HG8ayyquJuDMPSVLKDQKVh
This commit is contained in:
Your Name
2026-07-27 10:20:37 -05:00
co-authored by Claude Opus 5
parent e44e64436b
commit 5c63570c24
4 changed files with 217 additions and 3 deletions
+30
View File
@@ -24,6 +24,7 @@ const {
needsEncryptedLatchBackfill, isEncryptedButKeyless,
isGroupRecordKey, groupIdFromRecordKey,
} = require('./lib/groupRecord.js')
const { summariseSeederCoverage, seederCoverageLabel } = require('./lib/blindPeerListing.js')
const { raceAppend, APPEND_TIMEOUT_MS } = require('./lib/appendTimeout.js')
const { shouldSwallowFault, parseConflictLog } = require('./lib/conflictSeatbelt.js')
const { writerRewindStatus } = require('./lib/rewindGuard.js')
@@ -1124,6 +1125,18 @@ async function revokeGroupSeederRecord (pubkeyHex) {
// group), de-duped by pubkey. Group-only entries are marked `shared`.
async function listBlindPeers () {
const byPubkey = new Map()
// TODO #125 - which of THIS DEVICE'S current groups each seeder serves, counted
// fresh on every read. The `groupCount` stored on a seederFollow row is written
// once at pair time and never revisited, so rendering it was reporting a fossil:
// the TCL showed "Seeding 2 groups" for a seeder serving one group it was not
// in. Derived state cannot go stale, so derive it.
const servedByPubkey = new Map()
for await (const { value } of db.createReadStream({ gt: 'groupSeeder:', lt: 'groupSeeder:\xff' })) {
if (!value?.pubkey || value.revoked === true || !value.groupId) continue
if (!servedByPubkey.has(value.pubkey)) servedByPubkey.set(value.pubkey, new Set())
servedByPubkey.get(value.pubkey).add(value.groupId)
}
const liveGroupIds = new Set(bases.keys())
for await (const { value } of db.createReadStream({ gt: 'seederFollow:', lt: 'seederFollow:\xff' })) {
if (!value?.pubkey) continue
// `nickname` = this device's local rename override; `seederName` = the seeder's
@@ -1132,11 +1145,20 @@ async function listBlindPeers () {
// exposes both so the rename input can pre-fill the raw override + placeholder.
const override = value.nickname ?? null
const seederName = value.seederName ?? null
const coverage = summariseSeederCoverage({
servedGroupIds: servedByPubkey.get(value.pubkey) ?? [],
liveGroupIds,
})
byPubkey.set(value.pubkey, {
...value,
override,
seederName,
nickname: resolveSeederDisplayName({ override, seederName }),
// Live coverage, plus the label so the two UIs cannot word it differently.
// `groupCount` is left on the record for compatibility but must not be
// displayed - it is the pair-time fossil this replaces.
...coverage,
coverageLabel: seederCoverageLabel(coverage),
})
}
for await (const { value } of db.createReadStream({ gt: 'groupSeeder:', lt: 'groupSeeder:\xff' })) {
@@ -1152,6 +1174,12 @@ async function listBlindPeers () {
local.nickname = resolveSeederDisplayName({ override: local.override, seederName: local.seederName, groupName })
local.shared = true
} else {
// Known only via a group-shared record. Its coverage comes from the same
// live rows (TODO #125), so this path reports a real count too.
const coverage = summariseSeederCoverage({
servedGroupIds: servedByPubkey.get(value.pubkey) ?? [],
liveGroupIds,
})
byPubkey.set(value.pubkey, {
pubkey: value.pubkey,
override: null,
@@ -1161,6 +1189,8 @@ async function listBlindPeers () {
autoFollow: false,
shared: true,
via: 'group-record',
...coverage,
coverageLabel: seederCoverageLabel(coverage),
})
}
}
+80
View File
@@ -0,0 +1,80 @@
// TODO #125 - a followed blind peer stayed listed forever with a group count
// frozen at pair time.
//
// Two different things were wrong and it is worth keeping them apart.
//
// 1. THE COUNT WAS A FOSSIL. `seederFollow:` rows store `groupCount: enrolled`,
// written once when the seeder was paired, and the list rendered it as if it
// were current. Observed on the TCL: "Seeding 2 groups" for a seeder that was
// actually serving one group the device was not even in.
//
// 2. NOTHING EVER SAID "this serves nothing of yours". `listBlindPeers` filters
// group-shared `groupSeeder:` rows against live groups - the "reappears
// seeding 0 groups" orphan guard - but applied no equivalent to local
// follows, so an admitted seeder stayed on the list regardless.
//
// The fix for (1) is to stop reporting a cached number at all: count the live
// `groupSeeder:` rows each time the list is read. That is derived state, so it
// cannot go stale.
//
// The fix for (2) is deliberately to MARK, not hide, per the item: silently
// dropping a seeder the user chose to admit is its own confusion. Removal should
// be an informed choice.
//
// The `null` case matters. A device with no groups at all cannot say anything
// useful about whether a seeder serves "your groups" - the answer is vacuously
// no, and showing "not seeding any of your groups" to someone with no groups
// reads as a fault. So the decision has three states and the UI stays quiet on
// the third.
'use strict'
// How this seeder stands relative to the groups this device actually has.
//
// { groupsServed, servesCurrentGroups }
//
// groupsServed how many of THIS DEVICE'S current groups it serves,
// counted fresh. Never the cached pair-time number.
// servesCurrentGroups true / false / null, where null means "no useful
// answer" rather than "no".
//
// `servedGroupIds` is every groupId with a `groupSeeder:` row for this pubkey;
// `liveGroupIds` is the set of groups open on this device. Both are supplied by
// the caller so this stays pure.
function summariseSeederCoverage ({ servedGroupIds, liveGroupIds } = {}) {
const live = liveGroupIds instanceof Set ? liveGroupIds : new Set(liveGroupIds ?? [])
const served = new Set()
for (const id of servedGroupIds ?? []) {
if (id && live.has(id)) served.add(id)
}
const groupsServed = served.size
// No groups on this device: the question is not answerable in a way worth
// showing. Not the same as a seeder that serves none of several groups.
if (live.size === 0) return { groupsServed: 0, servesCurrentGroups: null }
return { groupsServed, servesCurrentGroups: groupsServed > 0 }
}
// What the list should show under a seeder's name, given the summary above.
//
// Returned as data rather than a bare string so the caller can style the warn
// case, and computed here rather than in the view so the wording travels with
// the decision. Only mobile renders a blind-peer list today - the desktop proxy
// has no `listBlindPeers` at all, just the older single-key API - so this is one
// UI for now, and the point is that a second one gets the sentence for free
// rather than inventing its own.
function seederCoverageLabel (summary) {
const { groupsServed, servesCurrentGroups } = summary ?? {}
if (servesCurrentGroups === null || servesCurrentGroups === undefined) return null
if (!servesCurrentGroups) {
return { tone: 'warn', text: 'Not seeding any of your groups' }
}
return {
tone: 'normal',
text: groupsServed === 1 ? 'Seeding 1 group' : `Seeding ${groupsServed} groups`,
}
}
module.exports = {
summariseSeederCoverage,
seederCoverageLabel,
}
+14 -3
View File
@@ -7729,9 +7729,20 @@ function ProfileTab ({ profile, groups, onUpdateProfile, db, events, setEvents,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{String(bp.pubkey).slice(0, 16)}
</div>
<div style={{ fontSize:11, color: colors.text.muted }}>
Seeding {bp.groupCount ?? 0} group{(bp.groupCount ?? 0) === 1 ? '' : 's'}
</div>
{/* TODO #125 - `groupCount` was the number cached when this
seeder was paired and never revisited, so it could claim
"Seeding 2 groups" for a seeder serving one group this
device is not even in. bare.js now counts live coverage on
every read and hands the wording over with it. A null
label means the question has no useful answer - this
device has no groups - so nothing is shown rather than
accusing the seeder of serving none of them. */}
{bp.coverageLabel && (
<div style={{ fontSize:11,
color: bp.coverageLabel.tone === 'warn' ? '#e0a458' : colors.text.muted }}>
{bp.coverageLabel.text}
</div>
)}
</div>
{confirming ? (
<div style={{ display:'flex', gap:6, flexShrink:0 }}>
+93
View File
@@ -0,0 +1,93 @@
// TODO #125 - a followed blind peer stayed listed forever with a group count
// frozen at pair time, and nothing ever said "this one serves nothing of yours".
// Pure decisions in src/lib/blindPeerListing.js.
// (bugfix/stale-blind-peers)
const test = require('node:test')
const assert = require('node:assert/strict')
const { summariseSeederCoverage, seederCoverageLabel } = require('../src/lib/blindPeerListing.js')
test('THE #125 CASE: serves only groups this device does not have', () => {
// Exactly what was seen on the TCL: the seeder was serving `Hudgins 2`, a
// group the debug app is not in, while the list said "Seeding 2 groups".
const s = summariseSeederCoverage({
servedGroupIds: ['hudgins2'],
liveGroupIds: ['gAAA', 'gBBB'],
})
assert.equal(s.groupsServed, 0)
assert.equal(s.servesCurrentGroups, false)
assert.deepEqual(seederCoverageLabel(s), { tone: 'warn', text: 'Not seeding any of your groups' })
})
test('the count is what it serves NOW, not what it served at pair time', () => {
// The seeder was paired when it held three groups; two are gone.
const s = summariseSeederCoverage({
servedGroupIds: ['gAAA', 'gGONE1', 'gGONE2'],
liveGroupIds: ['gAAA', 'gBBB'],
})
assert.equal(s.groupsServed, 1)
assert.equal(seederCoverageLabel(s).text, 'Seeding 1 group')
})
test('singular and plural read correctly', () => {
const one = summariseSeederCoverage({ servedGroupIds: ['a'], liveGroupIds: ['a', 'b'] })
const two = summariseSeederCoverage({ servedGroupIds: ['a', 'b'], liveGroupIds: ['a', 'b'] })
assert.equal(seederCoverageLabel(one).text, 'Seeding 1 group')
assert.equal(seederCoverageLabel(two).text, 'Seeding 2 groups')
})
test('a device with NO groups says nothing rather than accusing the seeder', () => {
// Vacuously it serves none of your groups, but showing that to someone with no
// groups reads as a fault in the seeder. Three states exist for this reason.
const s = summariseSeederCoverage({ servedGroupIds: [], liveGroupIds: [] })
assert.equal(s.servesCurrentGroups, null)
assert.equal(seederCoverageLabel(s), null)
})
test('a device with groups and a seeder serving none of them IS marked', () => {
// The distinction that makes the null state honest rather than a cop-out.
const s = summariseSeederCoverage({ servedGroupIds: [], liveGroupIds: ['gAAA'] })
assert.equal(s.servesCurrentGroups, false)
assert.equal(seederCoverageLabel(s).tone, 'warn')
})
test('duplicate rows for the same group count once', () => {
// One groupSeeder row per (group, pubkey), but a re-enrol or a mirror replay
// can produce the same groupId twice in the input.
const s = summariseSeederCoverage({
servedGroupIds: ['gAAA', 'gAAA', 'gAAA'],
liveGroupIds: ['gAAA'],
})
assert.equal(s.groupsServed, 1)
})
test('accepts a Set or an array for either input', () => {
const fromSets = summariseSeederCoverage({
servedGroupIds: new Set(['a']), liveGroupIds: new Set(['a', 'b']),
})
assert.equal(fromSets.groupsServed, 1)
assert.equal(fromSets.servesCurrentGroups, true)
})
test('empty, missing and junk inputs do not throw', () => {
for (const args of [undefined, {}, { servedGroupIds: null, liveGroupIds: null }]) {
const s = summariseSeederCoverage(args)
assert.equal(s.groupsServed, 0)
assert.equal(s.servesCurrentGroups, null)
}
assert.equal(seederCoverageLabel(undefined), null)
assert.equal(seederCoverageLabel({}), null)
})
test('falsy group ids are ignored rather than counted', () => {
const s = summariseSeederCoverage({ servedGroupIds: ['', null, undefined], liveGroupIds: ['gAAA'] })
assert.equal(s.groupsServed, 0)
})
test('the label is the single source of wording for both UIs', () => {
// Mobile and desktop render the same string from here. If one of them starts
// composing its own, this is the test that should have stopped it.
const s = summariseSeederCoverage({ servedGroupIds: ['a'], liveGroupIds: ['a'] })
const l = seederCoverageLabel(s)
assert.ok(l && typeof l.text === 'string' && l.text.length > 0)
assert.ok(['normal', 'warn'].includes(l.tone))
})