Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
d56e6ff
fix(network): stop tagging unverified peer-announce peers into peerstore
lukyrys Jul 23, 2026
177c621
fix(network): purge stale peers from autodial list and gossipsub dire…
lukyrys Jul 23, 2026
82780be
fix(network): evict peers unreachable across repeated redials
lukyrys Jul 23, 2026
b3f49a2
fix(settings): expire stale discovered peers from bootstrap status
lukyrys Jul 23, 2026
b778c15
fix(network): promote only connected peers to bootstrap set
lukyrys Jul 23, 2026
ebf6857
style: prettier reformatting (no logic change)
lukyrys Jul 23, 2026
2681c9f
fix(network): extract destination peer ID from circuit multiaddrs
lukyrys Jul 23, 2026
febfc2e
fix(network): reset redial failure history on live connections
lukyrys Jul 23, 2026
f8be473
fix(network): serialize status ticks and use fresh sweep snapshot
lukyrys Jul 23, 2026
be3622f
fix(network): evict peers with no reachable addresses after grace
lukyrys Jul 23, 2026
d66ebf6
fix(network): shorten unreachable quarantine to sweep TTL
lukyrys Jul 23, 2026
b7830d6
fix(network): compare destination identity in self-address skip
lukyrys Jul 23, 2026
e306d43
fix(network): keep connected peer on identity-mismatch of one addr
lukyrys Jul 23, 2026
53230db
fix(network): restore peer state when purge races inbound connect
lukyrys Jul 23, 2026
5c9a2f1
fix(network): scope status tick state writes to current run epoch
lukyrys Jul 23, 2026
f9248c8
fix(network): guard promote and redial success writes by run epoch
lukyrys Jul 23, 2026
ee444b3
fix(network): merge only dial-verified addresses into peerstore
lukyrys Jul 23, 2026
0ded60c
fix(network): canonicalize address comparison in mismatch trimming
lukyrys Jul 23, 2026
29f9afd
fix(network): merge only newly-dialed addresses and guard intake by e…
lukyrys Jul 23, 2026
bb1cd82
fix(settings): cap discovered rows and sweep by network membership
lukyrys Jul 23, 2026
7c2f903
test(network): anchor unsubscribeTopic slice past new getTopicPeers use
lukyrys Jul 23, 2026
41b74fb
fix(network): guard recordOutcome by run epoch after peerstore merge
lukyrys Jul 23, 2026
8121ef5
Merge remote-tracking branch 'origin/main' into fix/442-prune-stale-d…
lukyrys Jul 26, 2026
b852a99
docs(network): attach the misplaced JSDoc blocks to their functions
lukyrys Aug 6, 2026
43fc567
fix(network): scope the status-tick guard release to its own run
lukyrys Aug 6, 2026
349ff4c
fix(network): never evict peers while this node itself is offline
lukyrys Aug 9, 2026
005a651
Merge branch 'fix/428-leave-network-disconnect' into fix/442-prune-st…
lukyrys Aug 13, 2026
8b39273
Merge remote-tracking branch 'origin/main' into fix/442-prune-stale-d…
lukyrys Aug 13, 2026
e14111d
fix(network): bind peer eviction and recovery dials to the run epoch
lukyrys Aug 14, 2026
bf6d7b4
fix(network): require proof we are online before evicting an unreacha…
lukyrys Aug 14, 2026
5551a0e
fix(network): verify a bootstrap address by the connection libp2p ret…
lukyrys Aug 14, 2026
611845b
fix(network): compare the whole dial endpoint instead of a string prefix
lukyrys Aug 14, 2026
81ec976
fix(network): probe a configured bootstrap address instead of reusing…
lukyrys Aug 14, 2026
47d9e45
fix(network): end the eviction exemption when a peer leaves the config
lukyrys Aug 14, 2026
96c8cfa
fix(network): keep discovered participants when the bootstrap config …
lukyrys Aug 14, 2026
7dda2ae
fix(network): count only online failures towards peer eviction
lukyrys Aug 14, 2026
739cd38
fix(network): fold only DNS host case in multiaddr compare
lukyrys Aug 14, 2026
fe62d37
fix(network): abandon bootstrap dials for a superseded network config
lukyrys Aug 15, 2026
96874b8
fix(network): emit an empty participant list when the last row goes
lukyrys Aug 15, 2026
2826170
test(network): cover config supersede, unverified row and empty emit
lukyrys Aug 15, 2026
c6d7ffc
fix(lishnets): apply bootstrap list edits to the running node
lukyrys Aug 15, 2026
384bae4
fix(network): close a bootstrap dial that lands after leave-network
lukyrys Aug 15, 2026
70e4fd2
test(network): cover form-edited bootstrap list and post-leave dial
lukyrys Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions backend/src/lishnet/lishnets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ export class Networks {

this.network.unsubscribeTopic(id);
this.joinedNetworks.delete(id);
// Abandon any bootstrap job still walking this network's list. Left half-way
// through, it would keep dialing peers of a network we just left and clear the
// redial suppression the loop below is about to apply.
this.network.bumpBootstrapGeneration(id);

// Subscribers of any OTHER joined lishnet must stay connected (shared
// infrastructure). Compute this set BEFORE the bootstrap cleanup so that loop
Expand Down Expand Up @@ -384,7 +388,17 @@ export class Networks {
}

update(network: LISHNetworkConfig): boolean {
return updateLISHnet(this.db, network);
const existing = this.get(network.networkID);
const ok = updateLISHnet(this.db, network);
// The general edit form carries the bootstrap list as well, so this path can
// change it just like updateBootstrapPeers does. Without the same runtime
// synchronisation the edit would reach only the database and the live node
// would keep dialing the previous list until restart.
if (!ok || !existing) return ok;
const previous = Networks.cleanBootstrapList(existing.bootstrapPeers);
const cleaned = Networks.cleanBootstrapList(network.bootstrapPeers ?? []);
if (previous.join('\n') !== cleaned.join('\n')) this.syncBootstrapRuntime(network.networkID, existing.bootstrapPeers, cleaned);
return ok;
}

async delete(id: string): Promise<boolean> {
Expand Down Expand Up @@ -431,24 +445,42 @@ export class Networks {
async updateBootstrapPeers(id: string, bootstrapPeers: string[]): Promise<LISHNetworkConfig | null> {
const existing = this.get(id);
if (!existing) return null;
const cleaned = bootstrapPeers.filter(p => typeof p === 'string' && p.trim().length > 0);
// Drop the bootstrap-exemption for peer IDs removed from this network's
// config, unless still configured for another joined network. Prevents a
// removed bootstrap entry from lingering as infrastructure that a later
// leave-network would refuse to disconnect.
const cleaned = Networks.cleanBootstrapList(bootstrapPeers);
const next: LISHNetworkConfig = { ...existing, bootstrapPeers: cleaned };
updateLISHnet(this.db, next);
this.syncBootstrapRuntime(id, existing.bootstrapPeers, cleaned);
return next;
}

/** Drop blank entries from a user-supplied bootstrap list. */
private static cleanBootstrapList(peers: string[]): string[] {
return peers.filter(p => typeof p === 'string' && p.trim().length > 0);
}

/**
* Bring the running node in line with a network's new configured bootstrap list.
*
* Shared by the bootstrap-only editor and the general network edit form, because
* both can change that list and a change that reaches only the database leaves the
* live node working from the previous one until restart.
*
* Three things have to happen: peer IDs that left the list lose their
* bootstrap-exemption (unless another joined network still configures them, else a
* removed entry lingers as infrastructure a later leave refuses to disconnect),
* the status rows are pruned — which also invalidates any bootstrap job still
* walking the old list — and the new entries are dialed.
*/
private syncBootstrapRuntime(id: string, previousPeers: string[], cleaned: string[]): void {
const nextIDs = new Set(Networks.bootstrapPeerIDsOf(cleaned));
const elsewhere = this.configuredBootstrapPeerIDsElsewhere(id);
for (const pid of Networks.bootstrapPeerIDsOf(existing.bootstrapPeers)) {
for (const pid of Networks.bootstrapPeerIDsOf(previousPeers)) {
if (!nextIDs.has(pid) && !elsewhere.has(pid)) this.network.pruneConfiguredBootstrapPeer(pid);
}
const next: LISHNetworkConfig = { ...existing, bootstrapPeers: cleaned };
updateLISHnet(this.db, next);
this.network.pruneBootstrapStatus(id, cleaned);
if (this.joinedNetworks.has(id) && cleaned.length > 0) {
this.network.addBootstrapPeers(cleaned, id, 'configured').catch(err => {
console.error(`[Networks] re-dial after updateBootstrapPeers failed:`, err?.message ?? err);
console.error(`[Networks] bootstrap re-dial after config change failed:`, err?.message ?? err);
});
}
return next;
}
}
91 changes: 86 additions & 5 deletions backend/src/protocol/bootstrap-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { type BootstrapStatus, type BootstrapPeerStatus, type BootstrapPeerDialStatus, type BootstrapPeerOrigin } from '@shared';

/**
* Hard ceiling on DISCOVERED (gossip-learned) rows kept per network. A hostile
* topic subscriber can announce unbounded unique addresses, all ending in one
* connected peer's ID; without a cap the tracker map, its snapshots, and the
* WebSocket updates grow without limit. Configured rows are never counted or
* dropped — they are finite user data.
*/
const MAX_DISCOVERED_PER_NETWORK = 256;

/**
* Tracks per-network, per-bootstrap-peer dial outcome status.
*
Expand Down Expand Up @@ -48,6 +57,7 @@ export class BootstrapStatusTracker {
const previous = net.get(multiaddr);
const finalOrigin: BootstrapPeerOrigin = previous?.origin === 'configured' ? 'configured' : origin;
net.set(multiaddr, { multiaddr, expectedPeerID, status: 'pending', origin: finalOrigin, actualPeerID: null, lastError: null, updatedAt: new Date().toISOString() });
this.capDiscovered(net);
const snapshot = this.buildStatus(networkID);
if (snapshot) this.onStatusChange?.(networkID, snapshot);
}
Expand All @@ -60,10 +70,20 @@ export class BootstrapStatusTracker {
const previous = net.get(multiaddr);
const finalOrigin: BootstrapPeerOrigin = previous?.origin === 'configured' ? 'configured' : origin;
net.set(multiaddr, { multiaddr, expectedPeerID, status, origin: finalOrigin, actualPeerID, lastError: truncated, updatedAt: new Date().toISOString() });
this.capDiscovered(net);
const snapshot = this.buildStatus(networkID);
if (snapshot) this.onStatusChange?.(networkID, snapshot);
}

/** Bound discovered rows per network (drop the oldest) — see MAX_DISCOVERED_PER_NETWORK. */
private capDiscovered(net: Map<string, BootstrapPeerStatus>): void {
let discovered = 0;
for (const p of net.values()) if (p.origin === 'discovered') discovered++;
if (discovered <= MAX_DISCOVERED_PER_NETWORK) return;
const oldestFirst = [...net.entries()].filter(([, p]) => p.origin === 'discovered').sort((a, b) => Date.parse(a[1].updatedAt) - Date.parse(b[1].updatedAt));
for (let i = 0; i < discovered - MAX_DISCOVERED_PER_NETWORK; i++) net.delete(oldestFirst[i]![0]);
}

/** Drop a single peer entry directly (used after identity-mismatch purge of discovered peers). */
deletePeer(networkID: string, multiaddr: string): void {
const net = this.stats.get(networkID);
Expand All @@ -74,17 +94,78 @@ export class BootstrapStatusTracker {
this.onStatusChange?.(networkID, snap);
}

/** Drop bootstrap status entries no longer in the configured peer list (after an update). */
/**
* Drop every discovered-origin entry recorded for the given peer identity, in
* every network. Used when a peer is evicted as unreachable — its gossip-learned
* rows are pure noise at that point. Configured rows are kept: they are user
* data and must stay visible (red) so the user can fix or remove them.
*/
deleteDiscoveredByPeerID(peerID: string): void {
for (const [networkID, peers] of [...this.stats]) {
let changed = false;
for (const [addr, p] of [...peers]) {
if (p.origin !== 'discovered') continue;
if (p.expectedPeerID !== peerID && p.actualPeerID !== peerID) continue;
peers.delete(addr);
changed = true;
}
if (!changed) continue;
if (peers.size === 0) this.stats.delete(networkID);
this.onStatusChange?.(networkID, this.buildStatus(networkID) ?? { networkID, peers: [] });
}
}

/**
* Drop discovered-origin entries that have gone stale: no status refresh within
* `ttlMs` AND the peer is not an active member of THAT network. Dead peers stop
* being mentioned by gossip, so their rows stop refreshing and expire here —
* including rows frozen at 'connected' for a peer that silently died. The
* liveness predicate is scoped to the network (its topic subscribers), NOT the
* shared libp2p connection: a peer that left network B but is still connected
* through network A must not keep a stale row under B. Configured entries are
* exempt (user data). `now` is injectable for tests.
*/
sweepStale(ttlMs: number, isMember: (networkID: string, peerID: string) => boolean, now: number = Date.now()): void {
for (const [networkID, peers] of [...this.stats]) {
let changed = false;
for (const [addr, p] of [...peers]) {
if (p.origin !== 'discovered') continue;
const pid = p.expectedPeerID ?? p.actualPeerID;
if (pid && isMember(networkID, pid)) continue;
const updated = Date.parse(p.updatedAt);
if (Number.isFinite(updated) && now - updated < ttlMs) continue;
peers.delete(addr);
changed = true;
}
if (!changed) continue;
if (peers.size === 0) this.stats.delete(networkID);
this.onStatusChange?.(networkID, this.buildStatus(networkID) ?? { networkID, peers: [] });
}
}

/**
* Drop bootstrap status entries no longer in the configured peer list (after an update).
*
* `keepMultiaddrs` is the network's CONFIGURED list, so only configured rows may be
* judged by it. Discovered rows are not in it and never will be — deleting them here
* would clear the participant list of everything gossip has found, on nothing more
* than a bootstrap edit or a "refresh from public list", until gossip happens to
* mention each peer again. Discovered rows leave via their own paths: the staleness
* sweep, the per-network cap, or eviction of the peer ID.
*/
pruneEntries(networkID: string, keepMultiaddrs: string[]): void {
const peers = this.stats.get(networkID);
if (!peers) return;
const keep = new Set(keepMultiaddrs);
for (const addr of [...peers.keys()]) {
if (!keep.has(addr)) peers.delete(addr);
for (const [addr, peer] of [...peers.entries()]) {
if (peer.origin === 'configured' && !keep.has(addr)) peers.delete(addr);
}
if (peers.size === 0) this.stats.delete(networkID);
const snapshot = this.buildStatus(networkID);
if (snapshot) this.onStatusChange?.(networkID, snapshot);
// Emit the empty list rather than nothing when the last row goes: buildStatus
// returns null for a dropped network, and skipping the callback would leave the
// UI showing the very row that was just removed. Same fallback the other
// removal paths use.
this.onStatusChange?.(networkID, this.buildStatus(networkID) ?? { networkID, peers: [] });
}

/** Reset the bootstrap status for a single network (used when re-joining). */
Expand Down
Loading