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:
co-authored by
Claude Opus 5
parent
e44e64436b
commit
5c63570c24
+30
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
Reference in New Issue
Block a user